opencastle 0.25.0 → 0.26.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 (49) 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/convoy/engine.d.ts.map +1 -1
  10. package/dist/cli/convoy/engine.js +63 -0
  11. package/dist/cli/convoy/engine.js.map +1 -1
  12. package/dist/cli/convoy/export.d.ts.map +1 -1
  13. package/dist/cli/convoy/export.js +4 -0
  14. package/dist/cli/convoy/export.js.map +1 -1
  15. package/dist/cli/init.d.ts.map +1 -1
  16. package/dist/cli/init.js +15 -9
  17. package/dist/cli/init.js.map +1 -1
  18. package/dist/cli/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +4 -0
  20. package/dist/cli/run.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/cli/bootstrap.test.ts +286 -0
  23. package/src/cli/bootstrap.ts +472 -0
  24. package/src/cli/convoy/engine.ts +63 -0
  25. package/src/cli/convoy/export.ts +8 -0
  26. package/src/cli/init.ts +17 -12
  27. package/src/cli/run.ts +4 -0
  28. package/src/dashboard/dist/_astro/{index.Cq68OHaZ.css → index.DtnyD8a5.css} +1 -1
  29. package/src/dashboard/dist/index.html +61 -4
  30. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  31. package/src/dashboard/src/pages/index.astro +60 -3
  32. package/src/dashboard/src/styles/dashboard.css +4 -0
  33. package/src/orchestrator/agents/team-lead.agent.md +4 -2
  34. package/src/orchestrator/customizations/README.md +3 -3
  35. package/src/orchestrator/customizations/agents/agent-registry.md +1 -1
  36. package/src/orchestrator/customizations/project/docs-structure.md +1 -1
  37. package/src/orchestrator/customizations/project/roadmap.md +1 -1
  38. package/src/orchestrator/customizations/project/tracker-config.md +1 -1
  39. package/src/orchestrator/customizations/project.instructions.md +2 -2
  40. package/src/orchestrator/customizations/stack/api-config.md +1 -1
  41. package/src/orchestrator/customizations/stack/cms-config.md +2 -2
  42. package/src/orchestrator/customizations/stack/data-pipeline-config.md +1 -1
  43. package/src/orchestrator/customizations/stack/database-config.md +2 -2
  44. package/src/orchestrator/customizations/stack/deployment-config.md +1 -1
  45. package/src/orchestrator/customizations/stack/notifications-config.md +1 -1
  46. package/src/orchestrator/customizations/stack/testing-config.md +1 -1
  47. package/src/orchestrator/instructions/general.instructions.md +2 -0
  48. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +127 -132
  49. 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
+ }
@@ -271,6 +271,27 @@ async function runConvoy(
271
271
  { reason: 'timeout', worker_id: workerId },
272
272
  { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
273
273
  )
274
+ events.emit('session', {
275
+ agent: taskRecord.agent,
276
+ model: taskRecord.model ?? taskAdapter.name,
277
+ task: taskRecord.id,
278
+ outcome: 'failed',
279
+ duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
280
+ files_changed: 0,
281
+ retries: freshRecord.retries,
282
+ convoy_id: convoyId,
283
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
284
+ events.emit('delegation', {
285
+ session_id: convoyId,
286
+ agent: taskRecord.agent,
287
+ model: taskRecord.model ?? taskAdapter.name,
288
+ tier: 'standard',
289
+ mechanism: 'convoy',
290
+ outcome: 'failed',
291
+ retries: freshRecord.retries,
292
+ phase: taskRecord.phase,
293
+ convoy_id: convoyId,
294
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
274
295
  cascadeFailure(taskRecord.id)
275
296
  }
276
297
  taskAdapterMap.delete(taskRecord.id)
@@ -315,6 +336,27 @@ async function runConvoy(
315
336
  { exit_code: result.exitCode, worker_id: workerId },
316
337
  { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
317
338
  )
339
+ events.emit('session', {
340
+ agent: taskRecord.agent,
341
+ model: taskRecord.model ?? taskAdapter.name,
342
+ task: taskRecord.id,
343
+ outcome: 'success',
344
+ duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
345
+ files_changed: 0,
346
+ retries: taskRecord.retries,
347
+ convoy_id: convoyId,
348
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
349
+ events.emit('delegation', {
350
+ session_id: convoyId,
351
+ agent: taskRecord.agent,
352
+ model: taskRecord.model ?? taskAdapter.name,
353
+ tier: 'standard',
354
+ mechanism: 'convoy',
355
+ outcome: 'success',
356
+ retries: taskRecord.retries,
357
+ phase: taskRecord.phase,
358
+ convoy_id: convoyId,
359
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
318
360
  taskAdapterMap.delete(taskRecord.id)
319
361
  return
320
362
  }
@@ -354,6 +396,27 @@ async function runConvoy(
354
396
  { reason: 'error', exit_code: result.exitCode, worker_id: workerId },
355
397
  { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
356
398
  )
399
+ events.emit('session', {
400
+ agent: taskRecord.agent,
401
+ model: taskRecord.model ?? taskAdapter.name,
402
+ task: taskRecord.id,
403
+ outcome: 'failed',
404
+ duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
405
+ files_changed: 0,
406
+ retries: freshRecord.retries,
407
+ convoy_id: convoyId,
408
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
409
+ events.emit('delegation', {
410
+ session_id: convoyId,
411
+ agent: taskRecord.agent,
412
+ model: taskRecord.model ?? taskAdapter.name,
413
+ tier: 'standard',
414
+ mechanism: 'convoy',
415
+ outcome: 'failed',
416
+ retries: freshRecord.retries,
417
+ phase: taskRecord.phase,
418
+ convoy_id: convoyId,
419
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
357
420
  cascadeFailure(taskRecord.id)
358
421
  }
359
422
  taskAdapterMap.delete(taskRecord.id)
@@ -62,6 +62,13 @@ export async function exportConvoyToNdjson(
62
62
  timedOut: tasks.filter((t) => t.status === 'timed-out').length,
63
63
  }
64
64
 
65
+ const durationSec =
66
+ convoy.started_at && convoy.finished_at
67
+ ? Math.round(
68
+ (new Date(convoy.finished_at).getTime() - new Date(convoy.started_at).getTime()) / 1_000,
69
+ )
70
+ : undefined
71
+
65
72
  const record = {
66
73
  id: convoy.id,
67
74
  name: convoy.name,
@@ -70,6 +77,7 @@ export async function exportConvoyToNdjson(
70
77
  created_at: convoy.created_at,
71
78
  started_at: convoy.started_at,
72
79
  finished_at: convoy.finished_at,
80
+ duration_sec: durationSec,
73
81
  summary,
74
82
  tasks: tasks.map((t) => ({
75
83
  id: t.id,
package/src/cli/init.ts CHANGED
@@ -11,11 +11,7 @@ import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedTo
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()
@@ -232,6 +228,22 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
232
228
  totalSkipped += sub.skipped.length
233
229
  }
234
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
+
235
247
  // ── Write manifest ──────────────────────────────────────────────
236
248
  const manifest = createManifest(pkg.version, ides[0], ides)
237
249
  manifest.managedPaths = allManagedPaths
@@ -322,13 +334,6 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
322
334
  )
323
335
  }
324
336
  step++
325
- const bootstrapLabel = ides.includes('vscode')
326
- ? termLink(c.cyan('"Bootstrap Customizations"'), 'vscode://GitHub.Copilot-Chat/chat?mode=Team%20Lead%20(OpenCastle)&prompt=%2Fbootstrap-customizations')
327
- : c.cyan('"Bootstrap Customizations"')
328
- console.log(
329
- ` ${step}. Run the ${bootstrapLabel} prompt to configure for your project`
330
- )
331
- step++
332
337
  console.log(` ${step}. Commit the .opencastle/ folder to your repository`)
333
338
  console.log()
334
339
 
package/src/cli/run.ts CHANGED
@@ -496,6 +496,8 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
496
496
  const pipelineResult = await pipelineOrchestrator.run()
497
497
  printPipelineResult(pipelineResult)
498
498
  if (pipelineDashboardResult) {
499
+ console.log(`\n ${c.dim('Results saved to .opencastle/logs/convoys.ndjson')}`)
500
+ console.log(` ${c.dim('View again:')} opencastle dashboard`)
499
501
  pipelineDashboardResult.server.close()
500
502
  }
501
503
  process.exit(pipelineResult.status !== 'done' ? 1 : 0)
@@ -536,6 +538,8 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
536
538
  const result = await engine.run()
537
539
  printConvoyResult(result)
538
540
  if (dashboardResult) {
541
+ console.log(`\n ${c.dim('Results saved to .opencastle/logs/convoys.ndjson')}`)
542
+ console.log(` ${c.dim('View again:')} opencastle dashboard`)
539
543
  dashboardResult.server.close()
540
544
  }
541
545
  process.exit(result.status !== 'done' ? 1 : 0)