@soederpop/luca 0.0.5 → 0.0.7

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 (211) hide show
  1. package/CLAUDE.md +10 -1
  2. package/bun.lock +1 -1
  3. package/commands/build-bootstrap.ts +78 -0
  4. package/commands/build-scaffolds.ts +24 -2
  5. package/commands/try-all-challenges.ts +543 -0
  6. package/commands/try-challenge.ts +100 -0
  7. package/docs/README.md +52 -80
  8. package/docs/TABLE-OF-CONTENTS.md +82 -51
  9. package/docs/apis/clients/elevenlabs.md +232 -8
  10. package/docs/apis/clients/graph.md +59 -8
  11. package/docs/apis/clients/openai.md +362 -2
  12. package/docs/apis/clients/rest.md +122 -2
  13. package/docs/apis/clients/websocket.md +71 -17
  14. package/docs/apis/features/agi/assistant.md +9 -3
  15. package/docs/apis/features/agi/assistants-manager.md +2 -2
  16. package/docs/apis/features/agi/claude-code.md +153 -14
  17. package/docs/apis/features/agi/conversation-history.md +15 -3
  18. package/docs/apis/features/agi/conversation.md +133 -20
  19. package/docs/apis/features/agi/openai-codex.md +90 -12
  20. package/docs/apis/features/agi/skills-library.md +23 -5
  21. package/docs/apis/features/node/container-link.md +59 -0
  22. package/docs/apis/features/node/content-db.md +1 -1
  23. package/docs/apis/features/node/disk-cache.md +1 -1
  24. package/docs/apis/features/node/dns.md +1 -0
  25. package/docs/apis/features/node/docker.md +2 -1
  26. package/docs/apis/features/node/esbuild.md +4 -3
  27. package/docs/apis/features/node/file-manager.md +13 -4
  28. package/docs/apis/features/node/fs.md +726 -171
  29. package/docs/apis/features/node/git.md +1 -0
  30. package/docs/apis/features/node/google-auth.md +23 -4
  31. package/docs/apis/features/node/google-calendar.md +14 -2
  32. package/docs/apis/features/node/google-docs.md +15 -2
  33. package/docs/apis/features/node/google-drive.md +21 -3
  34. package/docs/apis/features/node/google-sheets.md +14 -2
  35. package/docs/apis/features/node/grep.md +2 -0
  36. package/docs/apis/features/node/helpers.md +29 -0
  37. package/docs/apis/features/node/ink.md +2 -2
  38. package/docs/apis/features/node/networking.md +39 -4
  39. package/docs/apis/features/node/os.md +28 -0
  40. package/docs/apis/features/node/postgres.md +26 -4
  41. package/docs/apis/features/node/proc.md +37 -28
  42. package/docs/apis/features/node/process-manager.md +33 -5
  43. package/docs/apis/features/node/repl.md +1 -1
  44. package/docs/apis/features/node/runpod.md +1 -0
  45. package/docs/apis/features/node/secure-shell.md +7 -0
  46. package/docs/apis/features/node/semantic-search.md +12 -5
  47. package/docs/apis/features/node/sqlite.md +26 -4
  48. package/docs/apis/features/node/telegram.md +30 -5
  49. package/docs/apis/features/node/tts.md +17 -2
  50. package/docs/apis/features/node/ui.md +1 -1
  51. package/docs/apis/features/node/vault.md +4 -9
  52. package/docs/apis/features/node/vm.md +3 -12
  53. package/docs/apis/features/node/window-manager.md +128 -20
  54. package/docs/apis/features/web/asset-loader.md +13 -1
  55. package/docs/apis/features/web/container-link.md +59 -0
  56. package/docs/apis/features/web/esbuild.md +4 -3
  57. package/docs/apis/features/web/helpers.md +29 -0
  58. package/docs/apis/features/web/network.md +16 -2
  59. package/docs/apis/features/web/speech.md +16 -2
  60. package/docs/apis/features/web/vault.md +4 -9
  61. package/docs/apis/features/web/vm.md +3 -12
  62. package/docs/apis/features/web/voice.md +18 -1
  63. package/docs/apis/servers/express.md +18 -2
  64. package/docs/apis/servers/mcp.md +29 -4
  65. package/docs/apis/servers/websocket.md +34 -6
  66. package/docs/bootstrap/CLAUDE.md +100 -0
  67. package/docs/bootstrap/SKILL.md +222 -0
  68. package/docs/bootstrap/templates/about-command.ts +41 -0
  69. package/docs/bootstrap/templates/docs-models.ts +22 -0
  70. package/docs/bootstrap/templates/docs-readme.md +43 -0
  71. package/docs/bootstrap/templates/example-feature.ts +53 -0
  72. package/docs/bootstrap/templates/health-endpoint.ts +15 -0
  73. package/docs/bootstrap/templates/luca-cli.ts +25 -0
  74. package/docs/challenges/caching-proxy.md +16 -0
  75. package/docs/challenges/content-db-round-trip.md +14 -0
  76. package/docs/challenges/custom-command.md +9 -0
  77. package/docs/challenges/file-watcher-pipeline.md +11 -0
  78. package/docs/challenges/grep-audit-report.md +15 -0
  79. package/docs/challenges/multi-feature-dashboard.md +14 -0
  80. package/docs/challenges/process-orchestrator.md +17 -0
  81. package/docs/challenges/rest-api-server-with-client.md +12 -0
  82. package/docs/challenges/script-runner-with-vm.md +11 -0
  83. package/docs/challenges/simple-rest-api.md +15 -0
  84. package/docs/challenges/websocket-serve-and-client.md +11 -0
  85. package/docs/challenges/yaml-config-system.md +14 -0
  86. package/docs/command-system-overhaul.md +94 -0
  87. package/docs/examples/assistant/CORE.md +18 -0
  88. package/docs/examples/assistant/hooks.ts +3 -0
  89. package/docs/examples/assistant/tools.ts +10 -0
  90. package/docs/examples/window-manager-layouts.md +180 -0
  91. package/docs/in-memory-fs.md +4 -0
  92. package/docs/models.ts +13 -10
  93. package/docs/philosophy.md +4 -3
  94. package/docs/reports/console-hmr-design.md +170 -0
  95. package/docs/reports/helper-semantic-search.md +72 -0
  96. package/docs/scaffolds/client.md +29 -20
  97. package/docs/scaffolds/command.md +64 -50
  98. package/docs/scaffolds/endpoint.md +31 -36
  99. package/docs/scaffolds/feature.md +28 -18
  100. package/docs/scaffolds/selector.md +91 -0
  101. package/docs/scaffolds/server.md +18 -9
  102. package/docs/selectors.md +115 -0
  103. package/docs/sessions/custom-command/attempt-log-2.md +195 -0
  104. package/docs/sessions/file-watcher-pipeline/attempt-log-1.md +728 -0
  105. package/docs/sessions/file-watcher-pipeline/attempt-log-2.md +555 -0
  106. package/docs/sessions/grep-audit-report/attempt-log-1.md +289 -0
  107. package/docs/sessions/multi-feature-dashboard/attempt-log-2.md +679 -0
  108. package/docs/sessions/rest-api-server-with-client/attempt-log-1.md +1 -0
  109. package/docs/sessions/rest-api-server-with-client/attempt-log-3.md +920 -0
  110. package/docs/sessions/simple-rest-api/attempt-log-1.md +593 -0
  111. package/docs/sessions/websocket-serve-and-client/attempt-log-2.md +995 -0
  112. package/docs/tutorials/00-bootstrap.md +148 -0
  113. package/docs/tutorials/07-endpoints.md +7 -7
  114. package/docs/tutorials/08-commands.md +153 -72
  115. package/luca.cli.ts +3 -0
  116. package/package.json +6 -5
  117. package/public/index.html +1430 -0
  118. package/scripts/examples/using-ollama.ts +2 -1
  119. package/scripts/update-introspection-data.ts +2 -2
  120. package/src/agi/endpoints/experts.ts +1 -1
  121. package/src/agi/features/assistant.ts +7 -0
  122. package/src/agi/features/assistants-manager.ts +5 -5
  123. package/src/agi/features/claude-code.ts +263 -3
  124. package/src/agi/features/conversation-history.ts +7 -1
  125. package/src/agi/features/conversation.ts +26 -3
  126. package/src/agi/features/openai-codex.ts +26 -2
  127. package/src/agi/features/openapi.ts +6 -1
  128. package/src/agi/features/skills-library.ts +9 -1
  129. package/src/bootstrap/generated.ts +540 -0
  130. package/src/cli/cli.ts +64 -21
  131. package/src/client.ts +23 -357
  132. package/src/clients/civitai/index.ts +1 -1
  133. package/src/clients/client-template.ts +1 -1
  134. package/src/clients/comfyui/index.ts +13 -2
  135. package/src/clients/elevenlabs/index.ts +2 -1
  136. package/src/clients/graph.ts +87 -0
  137. package/src/clients/openai/index.ts +10 -1
  138. package/src/clients/rest.ts +207 -0
  139. package/src/clients/websocket.ts +176 -0
  140. package/src/command.ts +281 -34
  141. package/src/commands/bootstrap.ts +181 -0
  142. package/src/commands/chat.ts +5 -4
  143. package/src/commands/describe.ts +225 -2
  144. package/src/commands/help.ts +35 -9
  145. package/src/commands/index.ts +3 -0
  146. package/src/commands/introspect.ts +92 -2
  147. package/src/commands/prompt.ts +5 -6
  148. package/src/commands/run.ts +33 -10
  149. package/src/commands/save-api-docs.ts +49 -0
  150. package/src/commands/scaffold.ts +169 -23
  151. package/src/commands/select.ts +94 -0
  152. package/src/commands/serve.ts +10 -1
  153. package/src/container.ts +15 -0
  154. package/src/endpoint.ts +19 -0
  155. package/src/graft.ts +181 -0
  156. package/src/introspection/generated.agi.ts +12458 -8968
  157. package/src/introspection/generated.node.ts +10573 -7145
  158. package/src/introspection/generated.web.ts +1 -1
  159. package/src/introspection/index.ts +26 -0
  160. package/src/node/container.ts +6 -7
  161. package/src/node/features/content-db.ts +49 -2
  162. package/src/node/features/disk-cache.ts +16 -9
  163. package/src/node/features/dns.ts +16 -3
  164. package/src/node/features/docker.ts +16 -4
  165. package/src/node/features/esbuild.ts +20 -0
  166. package/src/node/features/file-manager.ts +184 -29
  167. package/src/node/features/fs.ts +704 -248
  168. package/src/node/features/git.ts +21 -8
  169. package/src/node/features/grep.ts +23 -3
  170. package/src/node/features/helpers.ts +372 -43
  171. package/src/node/features/networking.ts +39 -4
  172. package/src/node/features/opener.ts +28 -15
  173. package/src/node/features/os.ts +76 -0
  174. package/src/node/features/port-exposer.ts +11 -1
  175. package/src/node/features/postgres.ts +17 -1
  176. package/src/node/features/proc.ts +4 -1
  177. package/src/node/features/python.ts +63 -14
  178. package/src/node/features/repl.ts +11 -7
  179. package/src/node/features/runpod.ts +16 -3
  180. package/src/node/features/secure-shell.ts +27 -2
  181. package/src/node/features/semantic-search.ts +12 -1
  182. package/src/node/features/ui.ts +5 -69
  183. package/src/node/features/vm.ts +17 -0
  184. package/src/node/features/window-manager.ts +68 -20
  185. package/src/node.ts +5 -0
  186. package/src/scaffolds/generated.ts +492 -290
  187. package/src/scaffolds/template.ts +9 -0
  188. package/src/schemas/base.ts +46 -5
  189. package/src/selector.ts +282 -0
  190. package/src/server.ts +11 -0
  191. package/src/servers/express.ts +27 -12
  192. package/src/servers/socket.ts +45 -11
  193. package/src/web/clients/socket.ts +4 -1
  194. package/src/web/container.ts +2 -1
  195. package/src/web/features/network.ts +7 -1
  196. package/src/web/features/voice-recognition.ts +16 -1
  197. package/test/clients-servers.test.ts +2 -1
  198. package/test/command.test.ts +267 -0
  199. package/test-integration/assistants-manager.test.ts +10 -20
  200. package/tmp/.cache/luca-disk-cache/content-v2/sha512/1b/b5/c75b28794f00f94c4d609a98978e9420e9b7146d204a7fbf5b0b30477292581705d207c0100dabaac27eef540aaaece3374af75104a93219d4ec8bfb44e7 +1 -0
  201. package/tmp/.cache/luca-disk-cache/content-v2/sha512/da/df/1d90ce4e042abeb035a197832c6d6893420a747a056be773eb00e4f745a037d505c8db13dde7d36b36b6b893addbb7df0f5fe9f0c13e665f20056447318b +1 -0
  202. package/tmp/.cache/luca-disk-cache/content-v2/sha512/ed/04/e1d0c2a58c2db29b3921ca2affb3ea4febe831c53b38ebc21019fb799823aba6ed5b4611873d2cd25d422d49955b852a9c326da0d678899bc1c2c2960901 +1 -0
  203. package/tmp/.cache/luca-disk-cache/index-v5/00/13/572aa4c9a94f99eda999695d050cdd0ca7fe2d23a50af03234d4c8ce0791 +2 -0
  204. package/tmp/.cache/luca-disk-cache/index-v5/75/a9/cb61dc0f0589e8ec10a9aca27b834bc73884c479941042d22a2b22324cd3 +2 -0
  205. package/tmp/.cache/luca-disk-cache/index-v5/9f/0f/8b1f915ee64cfff7667dd96acd7a5ac0a96aa91a346e19cefd45909a9c9c +2 -0
  206. package/docs/apis/features/node/launcher-app-command-listener.md +0 -145
  207. package/docs/examples/launcher-app-command-listener.md +0 -120
  208. package/docs/tasks/web-container-helper-discovery.md +0 -71
  209. package/docs/todos.md +0 -1
  210. package/scripts/test-command-listener.ts +0 -123
  211. package/src/node/features/launcher-app-command-listener.ts +0 -389
@@ -0,0 +1,543 @@
1
+ import { z } from 'zod'
2
+ import { commands, CommandOptionsSchema } from '@soederpop/luca'
3
+ import type { ContainerContext } from '@soederpop/luca'
4
+
5
+ declare module '@soederpop/luca' {
6
+ interface AvailableCommands {
7
+ tryAllChallenges: ReturnType<typeof commands.registerHandler>
8
+ }
9
+ }
10
+
11
+ export const argsSchema = CommandOptionsSchema.extend({
12
+ 'batch-size': z.number().default(4).describe('Number of challenges to run in parallel per batch'),
13
+ 'time-limit': z.number().optional().describe('Override time limit in minutes for all challenges'),
14
+ 'dry-run': z.boolean().default(false).describe('List the batch schedule without running anything'),
15
+ })
16
+
17
+ // ─── Types ──────────────────────────────────────────────────────────────────
18
+
19
+ type ChallengeStatus = 'queued' | 'bootstrapping' | 'running' | 'done' | 'failed' | 'timeout'
20
+
21
+ interface ChallengeState {
22
+ id: string
23
+ slug: string
24
+ title: string
25
+ status: ChallengeStatus
26
+ startTime: number
27
+ durationMs: number
28
+ timeLimitMinutes: number
29
+ lastActivity: string
30
+ activityLines: string[]
31
+ lessonsWritten: boolean
32
+ attemptFolder: string
33
+ error: string | undefined
34
+ batchIndex: number
35
+ }
36
+
37
+ // ─── Constants ──────────────────────────────────────────────────────────────
38
+
39
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
40
+ const MAX_ACTIVITY_LINES = 50
41
+ const STRIP_ANSI = /\x1b\[[0-9;]*m/g
42
+
43
+ // ─── Helpers ────────────────────────────────────────────────────────────────
44
+
45
+ function formatElapsed(ms: number): string {
46
+ const s = Math.floor(ms / 1000)
47
+ const m = Math.floor(s / 60)
48
+ const sec = s % 60
49
+ return `${m}:${String(sec).padStart(2, '0')}`
50
+ }
51
+
52
+ function pushActivity(cs: ChallengeState, line: string) {
53
+ const clean = line.replace(STRIP_ANSI, '').trim()
54
+ if (!clean) return
55
+ cs.activityLines.push(clean)
56
+ if (cs.activityLines.length > MAX_ACTIVITY_LINES) {
57
+ cs.activityLines = cs.activityLines.slice(-MAX_ACTIVITY_LINES)
58
+ }
59
+ cs.lastActivity = clean.slice(0, 80)
60
+ if (/lessons\.md/i.test(clean)) {
61
+ cs.lessonsWritten = true
62
+ }
63
+ }
64
+
65
+ // ─── Orchestration ──────────────────────────────────────────────────────────
66
+
67
+ async function bootstrapFolder(container: any, folder: string) {
68
+ const result = await container.proc.spawnAndCapture('luca', ['bootstrap'], {
69
+ cwd: container.paths.resolve(folder),
70
+ onOutput: () => {},
71
+ onError: () => {},
72
+ })
73
+ if (result.exitCode !== 0) {
74
+ throw new Error(`bootstrap failed (exit ${result.exitCode}): ${result.stderr}`)
75
+ }
76
+ }
77
+
78
+ // Track active child processes for cleanup on abort
79
+ const activeChildProcesses = new Set<any>()
80
+
81
+ async function runChallenge(
82
+ cs: ChallengeState,
83
+ container: any,
84
+ sessionFolder: string,
85
+ abortSignal: { aborted: boolean },
86
+ ): Promise<void> {
87
+ if (abortSignal.aborted) return
88
+
89
+ const fs = container.feature('fs')
90
+ const attemptFolder = `${sessionFolder}/${cs.slug}`
91
+ cs.attemptFolder = attemptFolder
92
+ fs.ensureFolder(attemptFolder)
93
+
94
+ // Bootstrap
95
+ cs.status = 'bootstrapping'
96
+ cs.startTime = Date.now()
97
+ cs.lastActivity = 'bootstrapping...'
98
+ await bootstrapFolder(container, attemptFolder)
99
+
100
+ if (abortSignal.aborted) return
101
+
102
+ // Run claude via luca prompt
103
+ cs.status = 'running'
104
+ cs.lastActivity = 'claude starting...'
105
+
106
+ const promptArgs = [
107
+ 'prompt', 'claude', `docs/${cs.id}`,
108
+ '--exclude-sections', 'Internal Notes',
109
+ '--out-file', `${sessionFolder}/logs/${cs.slug}-session.md`,
110
+ '--in-folder', attemptFolder,
111
+ '--dont-touch-file',
112
+ ]
113
+
114
+ const timeLimitMs = cs.timeLimitMinutes * 60 * 1000
115
+
116
+ const promptProcess = container.proc.spawnAndCapture('luca', promptArgs, {
117
+ onStart: (childProcess: any) => {
118
+ activeChildProcesses.add(childProcess)
119
+ },
120
+ onOutput: (str: string) => {
121
+ for (const line of str.split('\n')) {
122
+ pushActivity(cs, line)
123
+ }
124
+ },
125
+ onError: (str: string) => {
126
+ for (const line of str.split('\n')) {
127
+ pushActivity(cs, line)
128
+ }
129
+ },
130
+ })
131
+
132
+ const timeout = new Promise<'timeout'>((resolve) => {
133
+ setTimeout(() => resolve('timeout'), timeLimitMs + 30_000)
134
+ })
135
+
136
+ const result = await Promise.race([
137
+ promptProcess.then(() => 'done' as const),
138
+ timeout,
139
+ ]).catch((err: any) => {
140
+ cs.error = err?.message || String(err)
141
+ return 'failed' as const
142
+ })
143
+
144
+ cs.durationMs = Date.now() - cs.startTime
145
+
146
+ if (result === 'timeout') {
147
+ cs.status = 'timeout'
148
+ cs.error = `Exceeded ${cs.timeLimitMinutes}min + 30s safety margin`
149
+ pushActivity(cs, `[TIMEOUT: ${cs.timeLimitMinutes} min limit reached]`)
150
+ } else if (result === 'failed') {
151
+ cs.status = 'failed'
152
+ pushActivity(cs, `[FAILED: ${cs.error}]`)
153
+ } else {
154
+ // Check if LESSONS.md was actually written
155
+ if (fs.existsSync(container.paths.resolve(attemptFolder, 'LESSONS.md'))) {
156
+ cs.lessonsWritten = true
157
+ }
158
+ cs.status = 'done'
159
+ }
160
+ }
161
+
162
+ async function runBatch(
163
+ batch: ChallengeState[],
164
+ container: any,
165
+ sessionFolder: string,
166
+ abortSignal: { aborted: boolean },
167
+ ): Promise<void> {
168
+ const results = await Promise.allSettled(
169
+ batch.map((cs) => runChallenge(cs, container, sessionFolder, abortSignal))
170
+ )
171
+
172
+ for (let i = 0; i < results.length; i++) {
173
+ const r = results[i]
174
+ if (r.status === 'rejected' && batch[i].status === 'running') {
175
+ batch[i].status = 'failed'
176
+ batch[i].error = String(r.reason)
177
+ batch[i].durationMs = Date.now() - batch[i].startTime
178
+ }
179
+ }
180
+ }
181
+
182
+ async function runSynthesis(
183
+ challenges: ChallengeState[],
184
+ container: any,
185
+ sessionFolder: string,
186
+ ): Promise<void> {
187
+ const fs = container.feature('fs')
188
+ const paths = container.paths
189
+
190
+ // Gather all LESSONS.md content
191
+ const lessonParts: string[] = []
192
+ const summaryParts: string[] = []
193
+
194
+ for (const cs of challenges) {
195
+ const lessonsPath = paths.resolve(cs.attemptFolder, 'LESSONS.md')
196
+ const statusLabel = cs.status === 'done' ? 'completed' : cs.status
197
+ const duration = formatElapsed(cs.durationMs)
198
+
199
+ summaryParts.push(`- **${cs.title}** (${cs.slug}): ${statusLabel} in ${duration}${cs.lessonsWritten ? '' : ' — no LESSONS.md'}`)
200
+
201
+ if (cs.lessonsWritten && fs.existsSync(lessonsPath)) {
202
+ const content = fs.readFile(lessonsPath) as string
203
+ lessonParts.push(`## ${cs.title} (${cs.slug})\n\n${content}`)
204
+ }
205
+ }
206
+
207
+ const done = challenges.filter(c => c.status === 'done').length
208
+ const failed = challenges.filter(c => c.status === 'failed' || c.status === 'timeout').length
209
+
210
+ const synthesisPrompt = `You are reviewing the results of a batch challenge evaluation session for the Luca framework.
211
+
212
+ ${done} challenges completed successfully. ${failed} challenges failed or timed out.
213
+
214
+ ## Challenge Results
215
+
216
+ ${summaryParts.join('\n')}
217
+
218
+ ## Individual LESSONS.md Files
219
+
220
+ ${lessonParts.length > 0 ? lessonParts.join('\n\n---\n\n') : '(No LESSONS.md files were produced)'}
221
+
222
+ ## Your Task
223
+
224
+ Write a RETRO.md file in the current directory that contains:
225
+
226
+ 1. **What Went Well** — patterns and capabilities that worked reliably across challenges
227
+ 2. **What Didn't Go Well** — common struggles, failures, and pain points
228
+ 3. **Actionable Improvements** — specific, concrete steps to improve the CLAUDE.md, SKILL.md, framework docs, or luca internals that would help future challenge runs succeed faster and more reliably
229
+ 4. **Challenge-by-Challenge Notes** — brief per-challenge observations worth preserving
230
+
231
+ Be specific and actionable. Reference concrete file paths, APIs, and patterns. This retro should directly inform what we work on next.`
232
+
233
+ // Write synthesis prompt to a temp file and run it through luca prompt
234
+ const synthPromptPath = paths.resolve(sessionFolder, '_synthesis-prompt.md')
235
+ fs.ensureFile(synthPromptPath, `---\nrepeatable: true\n---\n\n${synthesisPrompt}`, true)
236
+
237
+ fs.ensureFolder(paths.resolve(sessionFolder, 'logs'))
238
+
239
+ await container.proc.spawnAndCapture('luca', [
240
+ 'prompt', 'claude', synthPromptPath,
241
+ '--in-folder', sessionFolder,
242
+ '--out-file', `${sessionFolder}/logs/synthesis-session.md`,
243
+ '--dont-touch-file',
244
+ '--preserve-frontmatter',
245
+ ], {
246
+ onOutput: (str: string) => { process.stdout.write(str) },
247
+ onError: (str: string) => { process.stderr.write(str) },
248
+ })
249
+ }
250
+
251
+ // ─── Ink Dashboard ──────────────────────────────────────────────────────────
252
+
253
+ async function renderDashboard(
254
+ challenges: ChallengeState[],
255
+ container: any,
256
+ sessionFolder: string,
257
+ batchSize: number,
258
+ ): Promise<boolean> {
259
+ const ink = container.feature('ink', { enable: true })
260
+ await ink.loadModules()
261
+
262
+ const React = ink.React
263
+ const h = React.createElement
264
+ const { Box, Text } = ink.components
265
+ const { useApp, useInput, useStdout } = ink.hooks
266
+ const { useState, useEffect } = React
267
+
268
+ const numBatches = Math.ceil(challenges.length / batchSize)
269
+ let currentBatchIndex = 0
270
+ let allBatchesDone = false
271
+ let userAborted = false
272
+ const abortSignal = { aborted: false }
273
+
274
+ // Run batches in sequence outside React
275
+ const orchestrate = async () => {
276
+ for (let b = 0; b < numBatches; b++) {
277
+ if (abortSignal.aborted) break
278
+ currentBatchIndex = b
279
+ const start = b * batchSize
280
+ const batch = challenges.slice(start, start + batchSize)
281
+ await runBatch(batch, container, sessionFolder, abortSignal)
282
+ }
283
+ allBatchesDone = true
284
+ }
285
+
286
+ const orchestrationPromise = orchestrate().catch(() => { allBatchesDone = true })
287
+
288
+ function App() {
289
+ const { exit } = useApp()
290
+ const { stdout } = useStdout()
291
+ const [tick, setTick] = useState(0)
292
+ const [focusIdx, setFocusIdx] = useState(0)
293
+
294
+ const cols = stdout?.columns || 120
295
+ const rows = stdout?.rows || 40
296
+
297
+ useEffect(() => {
298
+ const timer = setInterval(() => setTick((t: number) => t + 1), 250)
299
+ return () => clearInterval(timer)
300
+ }, [])
301
+
302
+ useEffect(() => {
303
+ if (allBatchesDone) {
304
+ setTimeout(() => exit(), 600)
305
+ }
306
+ }, [tick])
307
+
308
+ useInput((input: string, key: any) => {
309
+ if (input === 'q' || (key.ctrl && input === 'c')) {
310
+ userAborted = true
311
+ abortSignal.aborted = true
312
+ // Kill all active child processes
313
+ for (const cp of activeChildProcesses) {
314
+ try { cp.kill?.('SIGTERM') } catch {}
315
+ }
316
+ activeChildProcesses.clear()
317
+ exit()
318
+ }
319
+ if (key.upArrow) setFocusIdx((i: number) => Math.max(0, i - 1))
320
+ if (key.downArrow) setFocusIdx((i: number) => Math.min(challenges.length - 1, i + 1))
321
+ })
322
+
323
+ const done = challenges.filter(c => c.status === 'done').length
324
+ const failed = challenges.filter(c => c.status === 'failed' || c.status === 'timeout').length
325
+ const running = challenges.filter(c => c.status === 'running' || c.status === 'bootstrapping').length
326
+ const queued = challenges.filter(c => c.status === 'queued').length
327
+
328
+ // Progress bar
329
+ const progress = challenges.length > 0 ? (done + failed) / challenges.length : 0
330
+ const barWidth = 20
331
+ const filled = Math.round(progress * barWidth)
332
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled)
333
+
334
+ // Spinner frame
335
+ const spinFrame = SPINNER[tick % SPINNER.length]
336
+
337
+ // Detail panel lines
338
+ const focused = challenges[focusIdx]
339
+ const detailHeight = Math.min(8, Math.max(rows - challenges.length - 8, 4))
340
+ const detailLines = focused ? focused.activityLines.slice(-detailHeight) : []
341
+
342
+ return h(Box, { flexDirection: 'column', width: cols },
343
+ // ── Header ──
344
+ h(Box, { paddingX: 1, marginBottom: 1, justifyContent: 'space-between' },
345
+ h(Text, { bold: true, color: '#61dafb' }, 'LUCA CHALLENGES'),
346
+ h(Text, null,
347
+ h(Text, { dimColor: true }, `Batch ${currentBatchIndex + 1}/${numBatches} `),
348
+ h(Text, { color: 'cyan' }, bar),
349
+ h(Text, { dimColor: true }, ` ${done + failed}/${challenges.length}`),
350
+ ),
351
+ ),
352
+ // ── Stats row ──
353
+ h(Box, { paddingX: 1, marginBottom: 1, gap: 2 },
354
+ h(Text, { color: 'green' }, `${done} done`),
355
+ h(Text, { color: 'red' }, `${failed} failed`),
356
+ running > 0
357
+ ? h(Text, { color: 'cyan' }, `${running} running`)
358
+ : null,
359
+ queued > 0
360
+ ? h(Text, { dimColor: true }, `${queued} queued`)
361
+ : null,
362
+ ),
363
+ // ── Challenge rows ──
364
+ ...challenges.map((cs, i) => {
365
+ const isFocused = i === focusIdx
366
+ const elapsed = cs.status === 'queued'
367
+ ? '--:--'
368
+ : cs.status === 'done' || cs.status === 'failed' || cs.status === 'timeout'
369
+ ? formatElapsed(cs.durationMs)
370
+ : formatElapsed(Date.now() - cs.startTime)
371
+
372
+ let icon = ' · '
373
+ let iconColor = 'gray'
374
+ if (cs.status === 'bootstrapping') { icon = ' ⚙ '; iconColor = 'yellow' }
375
+ else if (cs.status === 'running') { icon = ` ${spinFrame} `; iconColor = 'cyan' }
376
+ else if (cs.status === 'done') { icon = ' ✓ '; iconColor = 'green' }
377
+ else if (cs.status === 'failed') { icon = ' ✗ '; iconColor = 'red' }
378
+ else if (cs.status === 'timeout') { icon = ' ⏱ '; iconColor = 'yellow' }
379
+
380
+ const slugDisplay = cs.slug.slice(0, 36).padEnd(36)
381
+ const elapsedDisplay = elapsed.padStart(6)
382
+ const activityWidth = Math.max(0, cols - 52)
383
+ const activity = cs.lastActivity ? cs.lastActivity.slice(0, activityWidth) : ''
384
+ const lessonsTag = cs.lessonsWritten ? ' [L]' : ''
385
+
386
+ return h(Box, { key: cs.id, paddingX: 1 },
387
+ h(Text, { color: isFocused ? 'white' : undefined, bold: isFocused, inverse: isFocused },
388
+ h(Text, { color: iconColor }, icon),
389
+ h(Text, null, slugDisplay),
390
+ h(Text, { dimColor: !isFocused }, ` ${elapsedDisplay} `),
391
+ h(Text, { dimColor: true }, activity),
392
+ cs.lessonsWritten
393
+ ? h(Text, { color: 'green', bold: true }, lessonsTag)
394
+ : null,
395
+ ),
396
+ )
397
+ }),
398
+ // ── Detail panel ──
399
+ h(Box, {
400
+ flexDirection: 'column',
401
+ borderStyle: 'round',
402
+ borderColor: focused?.status === 'running' ? 'cyan'
403
+ : focused?.status === 'done' ? 'green'
404
+ : focused?.status === 'failed' || focused?.status === 'timeout' ? 'red'
405
+ : 'gray',
406
+ paddingX: 1,
407
+ marginTop: 1,
408
+ marginX: 1,
409
+ height: detailHeight + 2,
410
+ },
411
+ h(Box, { justifyContent: 'space-between' },
412
+ h(Text, { bold: true }, focused ? focused.title : ''),
413
+ focused && focused.status !== 'queued'
414
+ ? h(Text, { dimColor: true },
415
+ focused.status === 'running' || focused.status === 'bootstrapping'
416
+ ? formatElapsed(Date.now() - focused.startTime)
417
+ : formatElapsed(focused.durationMs),
418
+ )
419
+ : null,
420
+ ),
421
+ h(Text, { wrap: 'truncate', dimColor: true },
422
+ detailLines.length > 0 ? detailLines.join('\n') : '(waiting...)',
423
+ ),
424
+ ),
425
+ // ── Footer ──
426
+ h(Box, { paddingX: 1, marginTop: 1, gap: 3 },
427
+ h(Text, { dimColor: true }, '↑↓ navigate'),
428
+ h(Text, { dimColor: true }, 'q quit'),
429
+ ),
430
+ )
431
+ }
432
+
433
+ await ink.render(h(App))
434
+ await ink.waitUntilExit()
435
+
436
+ if (userAborted) return false
437
+
438
+ await orchestrationPromise
439
+ return true
440
+ }
441
+
442
+ // ─── Main Handler ───────────────────────────────────────────────────────────
443
+
444
+ export async function tryAllChallenges(options: z.infer<typeof argsSchema>, context: ContainerContext) {
445
+ const container = context.container as any
446
+ const fs = container.feature('fs')
447
+ const paths = container.paths
448
+ const batchSize = options['batch-size']
449
+
450
+ await container.docs.load()
451
+ const allChallenges = await container.docs.queries.challenges.fetchAll()
452
+
453
+ if (allChallenges.length === 0) {
454
+ container.ui.print('No challenges found in docs/challenges/')
455
+ return
456
+ }
457
+
458
+ // Build challenge states
459
+ const numBatches = Math.ceil(allChallenges.length / batchSize)
460
+ const challengeStates: ChallengeState[] = allChallenges.map((c: any, i: number) => ({
461
+ id: c.id,
462
+ slug: c.id.split('/').pop()!,
463
+ title: c.title || c.id.split('/').pop()!,
464
+ status: 'queued' as ChallengeStatus,
465
+ startTime: 0,
466
+ durationMs: 0,
467
+ timeLimitMinutes: options['time-limit'] ?? c.meta?.maxTime ?? 5,
468
+ lastActivity: '',
469
+ activityLines: [],
470
+ lessonsWritten: false,
471
+ attemptFolder: '',
472
+ error: undefined,
473
+ batchIndex: Math.floor(i / batchSize),
474
+ }))
475
+
476
+ // Dry run — just print the schedule
477
+ if (options['dry-run']) {
478
+ container.ui.print(`\n${allChallenges.length} challenges in ${numBatches} batches of ${batchSize}:\n`)
479
+ for (let b = 0; b < numBatches; b++) {
480
+ const batch = challengeStates.filter(c => c.batchIndex === b)
481
+ container.ui.print(` Batch ${b + 1}:`)
482
+ for (const cs of batch) {
483
+ container.ui.print(` - ${cs.slug} (${cs.timeLimitMinutes}min)`)
484
+ }
485
+ }
486
+ return
487
+ }
488
+
489
+ // Create session folder
490
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
491
+ const sessionFolder = paths.resolve(`attempts/session-${timestamp}`)
492
+ fs.ensureFolder(sessionFolder)
493
+ fs.ensureFolder(`${sessionFolder}/logs`)
494
+
495
+ container.ui.print(`Session: ${sessionFolder}`)
496
+ container.ui.print(`${allChallenges.length} challenges, ${numBatches} batches of ${batchSize}\n`)
497
+
498
+ // Run dashboard
499
+ const completed = await renderDashboard(challengeStates, container, sessionFolder, batchSize)
500
+
501
+ if (!completed) {
502
+ container.ui.print('\nAborted by user.')
503
+ process.exit(1)
504
+ }
505
+
506
+ // Print summary
507
+ const done = challengeStates.filter(c => c.status === 'done').length
508
+ const failed = challengeStates.filter(c => c.status === 'failed' || c.status === 'timeout').length
509
+ const withLessons = challengeStates.filter(c => c.lessonsWritten).length
510
+
511
+ container.ui.print(`\n${'─'.repeat(60)}`)
512
+ container.ui.print(`Results: ${done} done, ${failed} failed, ${withLessons} with LESSONS.md`)
513
+ container.ui.print(`Session folder: ${sessionFolder}`)
514
+
515
+ // Write a session manifest
516
+ const manifest = challengeStates.map(cs => ({
517
+ slug: cs.slug,
518
+ status: cs.status,
519
+ durationMs: cs.durationMs,
520
+ lessonsWritten: cs.lessonsWritten,
521
+ error: cs.error,
522
+ }))
523
+ fs.ensureFile(
524
+ paths.resolve(sessionFolder, 'manifest.json'),
525
+ JSON.stringify(manifest, null, 2),
526
+ true,
527
+ )
528
+
529
+ // Synthesis
530
+ if (withLessons > 0) {
531
+ container.ui.print(`\nRunning synthesis across ${withLessons} LESSONS.md files...\n`)
532
+ await runSynthesis(challengeStates, container, sessionFolder)
533
+ container.ui.print(`\nRetro written to ${sessionFolder}/RETRO.md`)
534
+ } else {
535
+ container.ui.print('\nNo LESSONS.md files produced — skipping synthesis.')
536
+ }
537
+ }
538
+
539
+ export default {
540
+ description: 'Run all challenges in parallel batches with a live dashboard, then synthesize lessons into a retro.',
541
+ argsSchema,
542
+ handler: tryAllChallenges,
543
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod'
2
+ import { commands, CommandOptionsSchema } from '@soederpop/luca'
3
+ import type { ContainerContext } from '@soederpop/luca'
4
+
5
+ declare module '@soederpop/luca' {
6
+ interface AvailableCommands {
7
+ tryChallenge: ReturnType<typeof commands.registerHandler>
8
+ }
9
+ }
10
+
11
+ export const argsSchema = CommandOptionsSchema.extend({
12
+ 'time-limit': z.number().optional().describe('Time limit in minutes (defaults to challenge maxTime, then 5)'),
13
+ list: z.boolean().default(false).describe('List available challenges')
14
+ })
15
+
16
+ export async function tryChallenge(options: z.infer<typeof argsSchema>, context: ContainerContext) {
17
+ const container = context.container as any
18
+ const fs = container.feature('fs')
19
+ let requestedChallengeId = options._[1]!
20
+
21
+ await container.docs.load()
22
+
23
+ const challenges = await container.docs.queries.challenges.fetchAll()
24
+
25
+ if (!requestedChallengeId) {
26
+ container.ui.print('Available challenges:')
27
+ challenges.forEach(c => container.ui.print(`- ${c.id.split('/').pop()}`))
28
+ return
29
+ }
30
+
31
+ requestedChallengeId = requestedChallengeId.startsWith('challenges/') ? requestedChallengeId : `challenges/${requestedChallengeId}`
32
+
33
+ const challenge = challenges.find(c => c.id === requestedChallengeId || c.id.split('/').pop() === requestedChallengeId)
34
+
35
+ // Derive slug from the challenge id (e.g. "challenges/build-an-api" -> "build-an-api")
36
+ const slug = challenge.id.split('/').pop()
37
+
38
+ // Determine attempt number by counting existing attempts for this challenge
39
+ fs.ensureFolder('attempts')
40
+ const existing = fs.existsSync('attempts')
41
+ ? (await fs.readdir('attempts') as string[]).filter((name: string) => name.startsWith(slug + '-attempt-'))
42
+ : []
43
+ const attemptNumber = existing.length + 1
44
+ const attemptFolder = `attempts/${slug}-attempt-${attemptNumber}`
45
+
46
+ fs.ensureFolder(attemptFolder)
47
+
48
+ const timeLimitMinutes = options['time-limit'] ?? challenge.meta?.maxTime ?? 5
49
+ const timeLimitMs = timeLimitMinutes * 60 * 1000
50
+
51
+ container.ui.print(`Running Challenge: ${challenge.title}`)
52
+ container.ui.print(`Attempt #${attemptNumber} in ${attemptFolder}`)
53
+ container.ui.print(`Time limit: ${timeLimitMinutes} minutes`)
54
+
55
+ // Bootstrap the attempt folder
56
+ await container.proc.spawnAndCapture('luca', ['bootstrap'], {
57
+ cwd: container.paths.resolve(attemptFolder),
58
+ onOutput: (str) => { console.log(str) },
59
+ onError: (str) => { console.error(str) },
60
+ })
61
+
62
+ const promptCommandArgs = [
63
+ 'prompt', 'claude', `docs/${challenge.id}`,
64
+ '--exclude-sections', 'Internal Notes',
65
+ '--out-file', `docs/sessions/${challenge.id.split('/').pop()}/attempt-log-${attemptNumber}.md`,
66
+ '--in-folder', attemptFolder, '--dont-touch-file'
67
+ ]
68
+
69
+ const promptProcess = container.proc.spawnAndCapture('luca', promptCommandArgs, {
70
+ onOutput: (str) => {
71
+ console.log(str)
72
+ },
73
+ onError: (str) => {
74
+ console.error(str)
75
+ },
76
+ onExit: () => {
77
+ console.log('Claude Exited')
78
+ // @ts-ignore
79
+ process.exit(0)
80
+ }
81
+ })
82
+
83
+ const timeout = new Promise<never>((_, reject) => {
84
+ setTimeout(() => {
85
+ reject(new Error(`Time limit of ${timeLimitMinutes} minutes reached`))
86
+ }, timeLimitMs)
87
+ })
88
+
89
+ try {
90
+ await Promise.race([promptProcess, timeout])
91
+ } catch (err: any) {
92
+ container.ui.print(err.message)
93
+ }
94
+ }
95
+
96
+ export default {
97
+ description: 'Try running one of the evaluation challenges.',
98
+ argsSchema,
99
+ handler: tryChallenge,
100
+ }