@enderfga/claw-orchestrator 3.7.0 → 4.0.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 (110) hide show
  1. package/README.md +19 -3
  2. package/dist/bin/cli.js +30 -7
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/src/autoloop/messages.d.ts +0 -2
  5. package/dist/src/autoloop/messages.js +0 -2
  6. package/dist/src/autoloop/messages.js.map +1 -1
  7. package/dist/src/autoloop/planner-tools.d.ts +3 -3
  8. package/dist/src/autoloop/planner-tools.js +3 -3
  9. package/dist/src/autoloop/runner.d.ts +1 -2
  10. package/dist/src/autoloop/runner.js +1 -2
  11. package/dist/src/autoloop/runner.js.map +1 -1
  12. package/dist/src/autoloop/types.d.ts +0 -2
  13. package/dist/src/autoloop/types.js +0 -2
  14. package/dist/src/autoloop/types.js.map +1 -1
  15. package/dist/src/council.js +1 -0
  16. package/dist/src/council.js.map +1 -1
  17. package/dist/src/dashboard/index.html +709 -8
  18. package/dist/src/embedded-server.js +172 -6
  19. package/dist/src/embedded-server.js.map +1 -1
  20. package/dist/src/index.js +222 -1
  21. package/dist/src/index.js.map +1 -1
  22. package/dist/src/session-manager.d.ts +82 -1
  23. package/dist/src/session-manager.js +264 -6
  24. package/dist/src/session-manager.js.map +1 -1
  25. package/dist/src/ultraapp/build-events.d.ts +46 -0
  26. package/dist/src/ultraapp/build-events.js +6 -0
  27. package/dist/src/ultraapp/build-events.js.map +1 -0
  28. package/dist/src/ultraapp/build.d.ts +39 -0
  29. package/dist/src/ultraapp/build.js +111 -0
  30. package/dist/src/ultraapp/build.js.map +1 -0
  31. package/dist/src/ultraapp/conventions.d.ts +8 -0
  32. package/dist/src/ultraapp/conventions.js +248 -0
  33. package/dist/src/ultraapp/conventions.js.map +1 -0
  34. package/dist/src/ultraapp/council-adapter.d.ts +49 -0
  35. package/dist/src/ultraapp/council-adapter.js +152 -0
  36. package/dist/src/ultraapp/council-adapter.js.map +1 -0
  37. package/dist/src/ultraapp/deploy.d.ts +45 -0
  38. package/dist/src/ultraapp/deploy.js +82 -0
  39. package/dist/src/ultraapp/deploy.js.map +1 -0
  40. package/dist/src/ultraapp/diff-apply.d.ts +15 -0
  41. package/dist/src/ultraapp/diff-apply.js +48 -0
  42. package/dist/src/ultraapp/diff-apply.js.map +1 -0
  43. package/dist/src/ultraapp/docker.d.ts +57 -0
  44. package/dist/src/ultraapp/docker.js +83 -0
  45. package/dist/src/ultraapp/docker.js.map +1 -0
  46. package/dist/src/ultraapp/feedback-classifier.d.ts +24 -0
  47. package/dist/src/ultraapp/feedback-classifier.js +85 -0
  48. package/dist/src/ultraapp/feedback-classifier.js.map +1 -0
  49. package/dist/src/ultraapp/files.d.ts +24 -0
  50. package/dist/src/ultraapp/files.js +79 -0
  51. package/dist/src/ultraapp/files.js.map +1 -0
  52. package/dist/src/ultraapp/fix-on-failure-session.d.ts +23 -0
  53. package/dist/src/ultraapp/fix-on-failure-session.js +48 -0
  54. package/dist/src/ultraapp/fix-on-failure-session.js.map +1 -0
  55. package/dist/src/ultraapp/fix-on-failure.d.ts +39 -0
  56. package/dist/src/ultraapp/fix-on-failure.js +69 -0
  57. package/dist/src/ultraapp/fix-on-failure.js.map +1 -0
  58. package/dist/src/ultraapp/host-strategy.d.ts +57 -0
  59. package/dist/src/ultraapp/host-strategy.js +205 -0
  60. package/dist/src/ultraapp/host-strategy.js.map +1 -0
  61. package/dist/src/ultraapp/interview-parser.d.ts +41 -0
  62. package/dist/src/ultraapp/interview-parser.js +83 -0
  63. package/dist/src/ultraapp/interview-parser.js.map +1 -0
  64. package/dist/src/ultraapp/interview-tools.d.ts +16 -0
  65. package/dist/src/ultraapp/interview-tools.js +36 -0
  66. package/dist/src/ultraapp/interview-tools.js.map +1 -0
  67. package/dist/src/ultraapp/json-patch.d.ts +6 -0
  68. package/dist/src/ultraapp/json-patch.js +78 -0
  69. package/dist/src/ultraapp/json-patch.js.map +1 -0
  70. package/dist/src/ultraapp/lifecycle.d.ts +38 -0
  71. package/dist/src/ultraapp/lifecycle.js +31 -0
  72. package/dist/src/ultraapp/lifecycle.js.map +1 -0
  73. package/dist/src/ultraapp/manager.d.ts +141 -0
  74. package/dist/src/ultraapp/manager.js +661 -0
  75. package/dist/src/ultraapp/manager.js.map +1 -0
  76. package/dist/src/ultraapp/narrator-prompt.d.ts +10 -0
  77. package/dist/src/ultraapp/narrator-prompt.js +54 -0
  78. package/dist/src/ultraapp/narrator-prompt.js.map +1 -0
  79. package/dist/src/ultraapp/narrator.d.ts +49 -0
  80. package/dist/src/ultraapp/narrator.js +83 -0
  81. package/dist/src/ultraapp/narrator.js.map +1 -0
  82. package/dist/src/ultraapp/patcher.d.ts +35 -0
  83. package/dist/src/ultraapp/patcher.js +159 -0
  84. package/dist/src/ultraapp/patcher.js.map +1 -0
  85. package/dist/src/ultraapp/router.d.ts +39 -0
  86. package/dist/src/ultraapp/router.js +139 -0
  87. package/dist/src/ultraapp/router.js.map +1 -0
  88. package/dist/src/ultraapp/spec-delta.d.ts +18 -0
  89. package/dist/src/ultraapp/spec-delta.js +35 -0
  90. package/dist/src/ultraapp/spec-delta.js.map +1 -0
  91. package/dist/src/ultraapp/spec.d.ts +82 -0
  92. package/dist/src/ultraapp/spec.js +125 -0
  93. package/dist/src/ultraapp/spec.js.map +1 -0
  94. package/dist/src/ultraapp/store.d.ts +67 -0
  95. package/dist/src/ultraapp/store.js +177 -0
  96. package/dist/src/ultraapp/store.js.map +1 -0
  97. package/dist/src/ultraapp/versions.d.ts +50 -0
  98. package/dist/src/ultraapp/versions.js +65 -0
  99. package/dist/src/ultraapp/versions.js.map +1 -0
  100. package/dist/src/validation.d.ts +4 -4
  101. package/dist/src/validation.js +6 -5
  102. package/dist/src/validation.js.map +1 -1
  103. package/openclaw.plugin.json +15 -1
  104. package/package.json +5 -2
  105. package/skills/SKILL.md +2 -2
  106. package/skills/references/autoloop.md +1 -1
  107. package/skills/references/mcp.md +2 -2
  108. package/skills/references/tools.md +183 -0
  109. package/skills/references/ultraapp.md +203 -0
  110. package/skills/ultraapp/SKILL.md +112 -0
@@ -0,0 +1,661 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { validateAppSpec } from './spec.js';
6
+ import { parseInterviewReply } from './interview-parser.js';
7
+ import { runToolCalls } from './interview-tools.js';
8
+ import { extractMetadata as defaultExtractMetadata, ingestUpload, validateLocalPath, defaultAllowedRoots, } from './files.js';
9
+ import { applyPatch } from './json-patch.js';
10
+ import { UltraappBuildQueue } from './build.js';
11
+ import { runCouncilSynth } from './council-adapter.js';
12
+ import { runFixOnFailure } from './fix-on-failure.js';
13
+ import { spawnFixerSessionWith } from './fix-on-failure-session.js';
14
+ import { deployArtifact } from './deploy.js';
15
+ import { dockerBuild, dockerRun, dockerRmi } from './docker.js';
16
+ import { hostBuild, hostRun, hostRmi } from './host-strategy.js';
17
+ import { startContainerAndRegister, stopContainerAndDeregister, deleteContainerAndDeregister } from './lifecycle.js';
18
+ import { Narrator } from './narrator.js';
19
+ import { classifyFeedback } from './feedback-classifier.js';
20
+ import { runPatcher } from './patcher.js';
21
+ import { startSpecDeltaInterview } from './spec-delta.js';
22
+ import { snapshotVersion, swapVersion, listVersions } from './versions.js';
23
+ const SESSION_KICKOFF = 'Begin the ultraapp interview now. Ask the first question per the skill contract.';
24
+ export class UltraappManager {
25
+ opts;
26
+ runs = new Map();
27
+ skillContent;
28
+ buildQueue;
29
+ narrators = new Map();
30
+ classifierSessions = new Set();
31
+ constructor(opts) {
32
+ this.opts = opts;
33
+ this.skillContent = loadSkill(opts.skillPath);
34
+ this.buildQueue = new UltraappBuildQueue({
35
+ worker: (runId, emit) => this.runBuild(runId, emit),
36
+ });
37
+ this.buildQueue.subscribe((ev) => this.onBuildEvent(ev));
38
+ }
39
+ get store() {
40
+ return this.opts.store;
41
+ }
42
+ async createRun() {
43
+ const runId = await this.opts.store.createRun();
44
+ const sessionName = `ultraapp-${runId}`;
45
+ await this.opts.sessionManager.startSession({
46
+ name: sessionName,
47
+ engine: 'claude',
48
+ model: 'claude-opus-4-7',
49
+ cwd: process.cwd(),
50
+ systemPrompt: this.skillContent,
51
+ permissionMode: 'bypassPermissions',
52
+ });
53
+ const run = { runId, sessionName, emitter: new EventEmitter() };
54
+ this.runs.set(runId, run);
55
+ void this.driveTurn(run, SESSION_KICKOFF);
56
+ return runId;
57
+ }
58
+ async submitAnswer(runId, answer) {
59
+ const run = this.requireRun(runId);
60
+ const text = answer.freeform ? answer.freeform : `I picked: ${answer.value}`;
61
+ await this.opts.store.appendChat(runId, { role: 'user', kind: 'answer', text });
62
+ this.emit(run, { type: 'chat', entry: { role: 'user', kind: 'answer', text } });
63
+ void this.driveTurn(run, text);
64
+ }
65
+ async applySpecEdit(runId, patch) {
66
+ const run = this.requireRun(runId);
67
+ const spec = await this.opts.store.readSpec(runId);
68
+ const next = applyPatch(spec, patch);
69
+ await this.opts.store.writeSpec(runId, next, 'manual-edit');
70
+ this.emit(run, { type: 'spec-updated', spec: next });
71
+ void this.driveTurn(run, `[system] User manually edited spec: ${JSON.stringify(patch)}. Re-evaluate later questions.`);
72
+ }
73
+ async addFile(runId, args) {
74
+ const run = this.requireRun(runId);
75
+ let ref;
76
+ if (args.kind === 'upload') {
77
+ ref = await ingestUpload(this.opts.store.examplesDir(runId), args.filename, args.data);
78
+ }
79
+ else {
80
+ validateLocalPath(args.absolutePath, { allow: defaultAllowedRoots() });
81
+ ref = args.absolutePath;
82
+ }
83
+ await this.opts.store.appendChat(runId, {
84
+ role: 'system',
85
+ kind: 'free',
86
+ text: `[file added] ${path.basename(ref)}`,
87
+ payload: { ref },
88
+ });
89
+ void this.driveTurn(run, `[system] User added file: ${ref}. You may call extract_metadata.`);
90
+ return { ref };
91
+ }
92
+ subscribe(runId, listener) {
93
+ const run = this.requireRun(runId);
94
+ run.emitter.on('event', listener);
95
+ return () => run.emitter.off('event', listener);
96
+ }
97
+ async startBuild(runId) {
98
+ const run = this.requireRun(runId);
99
+ // Strict validate before enqueueing — pipeline cross-refs + DAG must
100
+ // resolve. writeSpec only does the lax shape check during interview
101
+ // iteration; this is the gate that catches real spec errors.
102
+ const spec = await this.opts.store.readSpec(runId);
103
+ try {
104
+ validateAppSpec(spec);
105
+ }
106
+ catch (e) {
107
+ const reason = e.message;
108
+ const msg = `Cannot start build: spec is invalid — ${reason}`;
109
+ await this.opts.store.appendChat(runId, { role: 'system', kind: 'error', text: msg });
110
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'error', text: msg } });
111
+ this.emit(run, { type: 'error', message: reason });
112
+ throw new Error(reason);
113
+ }
114
+ await this.opts.store.setMode(runId, 'queued');
115
+ const text = 'Build queued.';
116
+ await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text });
117
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text } });
118
+ void this.buildQueue.enqueue(runId);
119
+ }
120
+ cancelBuild(runId) {
121
+ this.buildQueue.cancel(runId);
122
+ }
123
+ buildPosition(runId) {
124
+ return this.buildQueue.position(runId);
125
+ }
126
+ async runBuild(runId, emit) {
127
+ await this.opts.store.setMode(runId, 'building');
128
+ // Start the narrator before any build event fires so onBuildEvent can
129
+ // push synchronously without racing async session-spawn. Best-effort —
130
+ // narrator failure shouldn't sink the build.
131
+ const language = detectLanguage(await this.opts.store.readChat(runId));
132
+ const run = this.runs.get(runId);
133
+ if (run) {
134
+ const n = new Narrator({
135
+ runId,
136
+ sessionManager: this.opts.sessionManager,
137
+ language,
138
+ onChat: (text) => {
139
+ void this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text });
140
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text } });
141
+ },
142
+ });
143
+ this.narrators.set(runId, n);
144
+ try {
145
+ await n.start();
146
+ }
147
+ catch {
148
+ this.narrators.delete(runId);
149
+ }
150
+ }
151
+ emit({ type: 'build-start', runId });
152
+ const spec = await this.opts.store.readSpec(runId);
153
+ const runDir = this.opts.store.runDirAbsolute(runId);
154
+ const council = await runCouncilSynth({
155
+ spec,
156
+ runId,
157
+ runDir,
158
+ sessionManager: this.opts.sessionManager,
159
+ });
160
+ if (!council.ok) {
161
+ emit({ type: 'build-failed', runId, phase: 'council', reason: council.reason ?? 'unknown' });
162
+ await this.opts.store.setMode(runId, 'failed', council.reason);
163
+ return;
164
+ }
165
+ emit({ type: 'council-consensus', runId, rounds: council.rounds });
166
+ emit({ type: 'fix-start', runId });
167
+ const useDocker = this.opts.runtimeMode === 'docker';
168
+ const fix = await runFixOnFailure({
169
+ worktreePath: council.worktreePath,
170
+ maxRounds: 5,
171
+ // Reuse the same SessionManager so the fixer doesn't spawn a fresh one per fix.
172
+ spawnFixer: (a) => spawnFixerSessionWith(this.opts.sessionManager, a),
173
+ // Host mode skips the docker-build step (no Docker required).
174
+ steps: useDocker
175
+ ? undefined // default: install + build + test + docker build
176
+ : [
177
+ { cmd: 'npm', args: ['install'] },
178
+ { cmd: 'npm', args: ['run', 'build'] },
179
+ { cmd: 'npm', args: ['test'] },
180
+ ],
181
+ });
182
+ if (!fix.ok) {
183
+ emit({
184
+ type: 'build-failed',
185
+ runId,
186
+ phase: 'fix-on-failure',
187
+ reason: fix.lastError ?? 'budget exhausted',
188
+ });
189
+ await this.opts.store.setMode(runId, 'failed', fix.lastError);
190
+ return;
191
+ }
192
+ emit({ type: 'fix-complete', runId, rounds: fix.rounds });
193
+ const version = 'v1';
194
+ await this.opts.store.recordBuildArtifact(runId, {
195
+ worktreePath: council.worktreePath,
196
+ version,
197
+ });
198
+ await this.opts.store.setMode(runId, 'build-complete');
199
+ emit({ type: 'build-complete', runId, worktreePath: council.worktreePath });
200
+ // If a router is wired, continue into deploy. Without a router (legacy v0.2
201
+ // wiring or test setup), build-complete is the resting state.
202
+ if (!this.opts.router)
203
+ return;
204
+ await this.runDeployStage({
205
+ runId,
206
+ version,
207
+ worktreePath: council.worktreePath,
208
+ slug: spec.meta.name,
209
+ emit,
210
+ });
211
+ }
212
+ async runDeployStage(args) {
213
+ const router = this.opts.router;
214
+ await this.opts.store.setMode(args.runId, 'deploying');
215
+ const taken = new Set(router.list().map((r) => r.port));
216
+ const deploy = this.opts.deployFn ?? deployArtifact;
217
+ const useDocker = this.opts.runtimeMode === 'docker';
218
+ const dep = await deploy({
219
+ runId: args.runId,
220
+ version: args.version,
221
+ worktreePath: args.worktreePath,
222
+ slug: args.slug,
223
+ hostDataDir: this.opts.store.runDirAbsolute(args.runId),
224
+ // Host-spawn (default) runs the codebase as a regular Node process;
225
+ // Docker (opt-in) runs `docker build` + `docker run`. Both use the
226
+ // same orchestrator interface so the deploy state machine doesn't care.
227
+ dockerBuild: (a) => (useDocker ? dockerBuild(a) : hostBuild({ tag: a.tag, cwd: a.cwd, buildArgs: a.buildArgs })),
228
+ dockerRun: (a) => useDocker
229
+ ? dockerRun(a)
230
+ : hostRun({
231
+ ...a,
232
+ // Host runner needs the cwd; smuggle it through env (deploy.ts
233
+ // passes `image: tag` so we can't use that field).
234
+ // Also set DATA_DIR to a writable per-run path — the conventions
235
+ // tell generated codebases to use process.env.DATA_DIR ?? '/data'.
236
+ // In Docker mode that defaults to '/data' (a mounted volume).
237
+ // In host mode we point it at the run's host data dir.
238
+ env: {
239
+ ...a.env,
240
+ HOST_CWD: args.worktreePath,
241
+ DATA_DIR: path.join(this.opts.store.runDirAbsolute(args.runId), 'data'),
242
+ },
243
+ }),
244
+ router,
245
+ fetchFn: fetch,
246
+ takenPorts: taken,
247
+ });
248
+ if (!dep.ok) {
249
+ args.emit({
250
+ type: 'build-failed',
251
+ runId: args.runId,
252
+ phase: 'orchestrator',
253
+ reason: `deploy: ${dep.reason ?? 'unknown'}`,
254
+ });
255
+ await this.opts.store.setMode(args.runId, 'failed', dep.reason);
256
+ return;
257
+ }
258
+ await this.opts.store.recordDeploy(args.runId, args.version, {
259
+ url: dep.url,
260
+ port: dep.port,
261
+ containerName: dep.containerName,
262
+ imageTag: dep.imageTag,
263
+ });
264
+ await this.opts.store.setMode(args.runId, 'done');
265
+ const run = this.runs.get(args.runId);
266
+ if (run) {
267
+ this.emit(run, { type: 'app-url', url: dep.url, version: args.version });
268
+ }
269
+ }
270
+ async startContainer(runId) {
271
+ if (!this.opts.router)
272
+ return { ok: false, error: 'router not configured' };
273
+ const arts = await this.opts.store.readArtifacts(runId);
274
+ const latest = arts[arts.length - 1];
275
+ if (!latest?.deploy)
276
+ return { ok: false, error: 'no deployed artifact' };
277
+ const spec = await this.opts.store.readSpec(runId);
278
+ const useDocker = this.opts.runtimeMode === 'docker';
279
+ const r = await startContainerAndRegister(latest.deploy.containerName, spec.meta.name, latest.deploy.port, this.opts.router, useDocker ? undefined : { dockerStartFn: async (n) => (await import('./host-strategy.js')).hostStart(n) });
280
+ return r;
281
+ }
282
+ async stopContainer(runId) {
283
+ if (!this.opts.router)
284
+ return { ok: false, error: 'router not configured' };
285
+ const arts = await this.opts.store.readArtifacts(runId);
286
+ const latest = arts[arts.length - 1];
287
+ if (!latest?.deploy)
288
+ return { ok: false, error: 'no deployed artifact' };
289
+ const spec = await this.opts.store.readSpec(runId);
290
+ const useDocker = this.opts.runtimeMode === 'docker';
291
+ return stopContainerAndDeregister(latest.deploy.containerName, spec.meta.name, this.opts.router, useDocker ? undefined : { dockerStopFn: async (n) => (await import('./host-strategy.js')).hostStop(n) });
292
+ }
293
+ async deleteRun(runId) {
294
+ const arts = await this.opts.store.readArtifacts(runId).catch(() => []);
295
+ const latest = arts[arts.length - 1];
296
+ let spec = null;
297
+ try {
298
+ spec = await this.opts.store.readSpec(runId);
299
+ }
300
+ catch {
301
+ /* run already gone */
302
+ }
303
+ const useDocker = this.opts.runtimeMode === 'docker';
304
+ if (latest?.deploy && spec && this.opts.router) {
305
+ await deleteContainerAndDeregister(latest.deploy.containerName, spec.meta.name, this.opts.router, useDocker ? undefined : { dockerRmFn: async (n) => (await import('./host-strategy.js')).hostRm(n) });
306
+ const rmiFn = useDocker ? dockerRmi : hostRmi;
307
+ await rmiFn(latest.deploy.imageTag).catch(() => {
308
+ /* image may not exist */
309
+ });
310
+ }
311
+ // Stop & forget the active run if any
312
+ const run = this.runs.get(runId);
313
+ if (run) {
314
+ await this.opts.sessionManager.stopSession(run.sessionName).catch(() => { });
315
+ this.runs.delete(runId);
316
+ }
317
+ await this.opts.store.deleteRunFiles(runId);
318
+ return { ok: true };
319
+ }
320
+ /**
321
+ * Done-mode chat message — classified into cosmetic / spec-delta /
322
+ * structural and routed accordingly. Cosmetic runs the patcher inline;
323
+ * spec-delta flips mode back to interview with a focused bootstrap;
324
+ * structural posts a narrator note suggesting a new run.
325
+ */
326
+ async submitDoneModeMessage(runId, text) {
327
+ const run = this.requireRun(runId);
328
+ const state = await this.opts.store.readState(runId);
329
+ if (state.mode !== 'done') {
330
+ // Outside done mode, fall through to the standard interview answer flow
331
+ return this.submitAnswer(runId, { value: '', freeform: text });
332
+ }
333
+ await this.opts.store.appendChat(runId, { role: 'user', kind: 'free', text });
334
+ this.emit(run, { type: 'chat', entry: { role: 'user', kind: 'free', text } });
335
+ const spec = await this.opts.store.readSpec(runId);
336
+ const language = detectLanguage(await this.opts.store.readChat(runId));
337
+ const cls = await classifyFeedback({
338
+ text,
339
+ currentSpec: spec,
340
+ language,
341
+ llmCall: (p) => this.classifierCall(runId, p),
342
+ });
343
+ const announce = `I read this as a ${cls.class} change. ${cls.proposedAction}. Stop me if that's wrong.`;
344
+ await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text: announce });
345
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text: announce } });
346
+ await this.routeFeedback(runId, cls.class, text, spec);
347
+ }
348
+ /**
349
+ * Promote a previously-built version to the currently-deployed one.
350
+ * Stops the current container, starts the target's container, and updates
351
+ * the router map atomically.
352
+ */
353
+ async promoteVersion(runId, toVersion) {
354
+ if (!this.opts.router)
355
+ return { ok: false, error: 'router not configured' };
356
+ const versions = listVersions(this.opts.store.versionsDir(runId));
357
+ const current = versions.find((v) => v.deploy?.containerName);
358
+ const target = versions.find((v) => v.version === toVersion);
359
+ if (!target?.deploy)
360
+ return { ok: false, error: `version ${toVersion} has no deploy` };
361
+ const spec = await this.opts.store.readSpec(runId);
362
+ const r = await swapVersion({
363
+ versionsDir: this.opts.store.versionsDir(runId),
364
+ fromVersion: current?.version ?? toVersion,
365
+ toVersion,
366
+ slug: spec.meta.name,
367
+ router: this.opts.router,
368
+ startContainer: async (name) => {
369
+ const { dockerStart } = await import('./docker.js');
370
+ return dockerStart(name);
371
+ },
372
+ stopContainer: async (name) => {
373
+ const { dockerStop } = await import('./docker.js');
374
+ return dockerStop(name);
375
+ },
376
+ });
377
+ if (r.ok) {
378
+ const run = this.runs.get(runId);
379
+ if (run)
380
+ this.emit(run, { type: 'app-url', url: target.deploy.url, version: toVersion });
381
+ }
382
+ return r;
383
+ }
384
+ /** Used by spec-delta to push a system-style bootstrap into the run session. */
385
+ async injectSystemMessage(runId, text) {
386
+ const run = this.requireRun(runId);
387
+ await this.opts.sessionManager.sendMessage(run.sessionName, `[system] ${text}`);
388
+ }
389
+ /** Used by spec-delta to flip mode back to 'interview' for the focused
390
+ interview, AND mark the run so the next interview-complete auto-fires
391
+ a build (no need for the user to click Start Build for a delta rerun). */
392
+ async setModeForDelta(runId) {
393
+ await this.opts.store.setMode(runId, 'interview');
394
+ const run = this.runs.get(runId);
395
+ if (run) {
396
+ run.deltaPending = true;
397
+ const spec = await this.opts.store.readSpec(runId);
398
+ this.emit(run, { type: 'spec-updated', spec });
399
+ }
400
+ }
401
+ async classifierCall(runId, prompt) {
402
+ const sessionName = `classifier-${runId}`;
403
+ if (!this.classifierSessions.has(runId)) {
404
+ await this.opts.sessionManager.startSession({
405
+ name: sessionName,
406
+ engine: 'claude',
407
+ model: 'claude-haiku-4-5-20251001',
408
+ permissionMode: 'bypassPermissions',
409
+ });
410
+ this.classifierSessions.add(runId);
411
+ }
412
+ return this.opts.sessionManager.sendMessage(sessionName, prompt);
413
+ }
414
+ async routeFeedback(runId, klass, text, spec) {
415
+ const run = this.requireRun(runId);
416
+ if (klass === 'cosmetic') {
417
+ await this.runPatcherFlow(runId, text);
418
+ return;
419
+ }
420
+ if (klass === 'spec-delta') {
421
+ await startSpecDeltaInterview(this, runId, text, spec);
422
+ return;
423
+ }
424
+ // structural
425
+ const note = 'This sounds like a different app entirely. Click + New in the sidebar to start a fresh ultraapp run.';
426
+ await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text: note });
427
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text: note } });
428
+ }
429
+ async runPatcherFlow(runId, feedback) {
430
+ const run = this.requireRun(runId);
431
+ const versions = listVersions(this.opts.store.versionsDir(runId));
432
+ const current = versions.find((v) => v.deploy?.containerName) ?? versions[versions.length - 1];
433
+ if (!current?.worktreePath) {
434
+ const err = 'no buildable worktree found for patcher';
435
+ this.emit(run, { type: 'error', message: err });
436
+ return;
437
+ }
438
+ const patch = await runPatcher({
439
+ worktreePath: current.worktreePath,
440
+ feedback,
441
+ llmCall: (p) => this.patcherCall(runId, p),
442
+ validate: (a) => runFixOnFailure({ ...a, maxRounds: 3 }),
443
+ });
444
+ if (!patch.ok) {
445
+ const msg = `Patcher failed: ${patch.reason ?? 'unknown'}`;
446
+ await this.opts.store.appendChat(runId, { role: 'system', kind: 'error', text: msg });
447
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'error', text: msg } });
448
+ return;
449
+ }
450
+ const nextVersion = snapshotVersion(this.opts.store.versionsDir(runId), {
451
+ worktreePath: current.worktreePath,
452
+ source: 'patcher',
453
+ });
454
+ const msg = `Patched and saved as ${nextVersion}. Promote it from the AppSpec column to swap the deployed container.`;
455
+ await this.opts.store.appendChat(runId, { role: 'system', kind: 'narrator', text: msg });
456
+ this.emit(run, { type: 'chat', entry: { role: 'system', kind: 'narrator', text: msg } });
457
+ }
458
+ async patcherCall(runId, prompt) {
459
+ const sessionName = `patcher-${runId}-${Date.now()}`;
460
+ await this.opts.sessionManager.startSession({
461
+ name: sessionName,
462
+ engine: 'claude',
463
+ model: 'claude-opus-4-7',
464
+ permissionMode: 'bypassPermissions',
465
+ });
466
+ try {
467
+ return await this.opts.sessionManager.sendMessage(sessionName, prompt);
468
+ }
469
+ finally {
470
+ await this.opts.sessionManager.stopSession(sessionName).catch(() => { });
471
+ }
472
+ }
473
+ onBuildEvent(ev) {
474
+ const run = this.runs.get(ev.runId);
475
+ if (!run)
476
+ return;
477
+ // Always emit raw event for the dashboard mode pill.
478
+ this.emit(run, { type: 'build-event', event: ev });
479
+ // Push to the narrator (started in runBuild). The narrator owns chat
480
+ // narration now — no raw-line writes from this handler.
481
+ const narrator = this.narrators.get(ev.runId);
482
+ if (narrator)
483
+ narrator.push(ev);
484
+ // Tear down narrator on terminal events. push() above already triggered
485
+ // its urgent flush, so by the time we stop() any final summary is queued.
486
+ if (ev.type === 'build-complete' || ev.type === 'build-failed' || ev.type === 'build-cancelled') {
487
+ const n = this.narrators.get(ev.runId);
488
+ if (n) {
489
+ this.narrators.delete(ev.runId);
490
+ void n.stop();
491
+ }
492
+ }
493
+ }
494
+ async driveTurn(run, message) {
495
+ try {
496
+ const { output } = await this.opts.sessionManager.sendMessage(run.sessionName, message);
497
+ // Debug capture for trace authoring: when UA_DEBUG_TURNS=<dir> is set,
498
+ // append every turn's (in, out) pair to <dir>/<runId>.turns.jsonl. No
499
+ // effect when unset. Used by scripts/ua-capture-trace.mjs to rebuild a
500
+ // replayable JSONL trace from a real interview run.
501
+ const debugDir = process.env.UA_DEBUG_TURNS;
502
+ if (debugDir) {
503
+ try {
504
+ fs.mkdirSync(debugDir, { recursive: true });
505
+ fs.appendFileSync(path.join(debugDir, `${run.runId}.turns.jsonl`), JSON.stringify({ ts: new Date().toISOString(), in: message, out: output }) + '\n');
506
+ }
507
+ catch {
508
+ /* best-effort */
509
+ }
510
+ }
511
+ const parsed = parseInterviewReply(output);
512
+ if (parsed.kind === 'tools' || parsed.kind === 'tools-and-question') {
513
+ const results = await runToolCalls({
514
+ runId: run.runId,
515
+ store: this.opts.store,
516
+ extractMetadata: defaultExtractMetadata,
517
+ calls: parsed.toolCalls,
518
+ });
519
+ const spec = await this.opts.store.readSpec(run.runId);
520
+ this.emit(run, { type: 'spec-updated', spec });
521
+ const followup = results
522
+ .map((r) => `<tool_result name="${r.name}">${r.ok ? JSON.stringify(r.result) : `ERROR: ${r.error}`}</tool_result>`)
523
+ .join('\n');
524
+ if (parsed.kind === 'tools-and-question') {
525
+ // Claude already wrote the next question; surface it to the user
526
+ // immediately. Send the tool_result followup in the background so
527
+ // the LLM sees the tool succeeded — but we don't block the user
528
+ // on its reply (it's typically a "thanks, continuing" no-op).
529
+ await this.opts.store.appendChat(run.runId, {
530
+ role: 'assistant',
531
+ kind: 'question',
532
+ text: parsed.question.question,
533
+ payload: { ...parsed.question },
534
+ });
535
+ this.emit(run, { type: 'question', question: parsed.question });
536
+ void this.driveTurn(run, followup);
537
+ return;
538
+ }
539
+ void this.driveTurn(run, followup);
540
+ return;
541
+ }
542
+ if (parsed.kind === 'question') {
543
+ await this.opts.store.appendChat(run.runId, {
544
+ role: 'assistant',
545
+ kind: 'question',
546
+ text: parsed.question.question,
547
+ payload: { ...parsed.question },
548
+ });
549
+ this.emit(run, { type: 'question', question: parsed.question });
550
+ return;
551
+ }
552
+ if (parsed.kind === 'complete') {
553
+ await this.opts.store.appendChat(run.runId, {
554
+ role: 'assistant',
555
+ kind: 'narrator',
556
+ text: parsed.summary,
557
+ });
558
+ this.emit(run, { type: 'interview-complete', summary: parsed.summary });
559
+ // If this completion came out of a spec-delta focused interview, the
560
+ // user already signalled their intent to rebuild — auto-start so they
561
+ // don't have to click again. Best-effort: a strict-validation failure
562
+ // surfaces as an error chat entry and the user can fix + click Build.
563
+ if (run.deltaPending) {
564
+ run.deltaPending = false;
565
+ const note = 'Spec delta complete — auto-starting build.';
566
+ await this.opts.store.appendChat(run.runId, {
567
+ role: 'system',
568
+ kind: 'narrator',
569
+ text: note,
570
+ });
571
+ this.emit(run, {
572
+ type: 'chat',
573
+ entry: { role: 'system', kind: 'narrator', text: note },
574
+ });
575
+ this.startBuild(run.runId).catch((err) => {
576
+ const msg = `Auto-build failed: ${err.message}`;
577
+ void this.opts.store.appendChat(run.runId, {
578
+ role: 'system',
579
+ kind: 'error',
580
+ text: msg,
581
+ });
582
+ this.emit(run, {
583
+ type: 'chat',
584
+ entry: { role: 'system', kind: 'error', text: msg },
585
+ });
586
+ });
587
+ }
588
+ return;
589
+ }
590
+ if (parsed.kind === 'text') {
591
+ await this.opts.store.appendChat(run.runId, {
592
+ role: 'assistant',
593
+ kind: 'free',
594
+ text: parsed.text,
595
+ });
596
+ this.emit(run, {
597
+ type: 'chat',
598
+ entry: { role: 'assistant', kind: 'free', text: parsed.text },
599
+ });
600
+ return;
601
+ }
602
+ this.emit(run, { type: 'error', message: parsed.reason });
603
+ }
604
+ catch (e) {
605
+ this.emit(run, { type: 'error', message: e.message });
606
+ }
607
+ }
608
+ emit(run, ev) {
609
+ run.emitter.emit('event', ev);
610
+ }
611
+ requireRun(runId) {
612
+ const r = this.runs.get(runId);
613
+ if (!r)
614
+ throw new Error(`unknown runId: ${runId}`);
615
+ return r;
616
+ }
617
+ }
618
+ function detectLanguage(chat) {
619
+ for (const e of chat) {
620
+ if (e.role !== 'user')
621
+ continue;
622
+ if (/[一-龥]/.test(e.text))
623
+ return 'zh';
624
+ return 'en';
625
+ }
626
+ return 'en';
627
+ }
628
+ function loadSkill(skillPath) {
629
+ if (skillPath) {
630
+ try {
631
+ return fs.readFileSync(skillPath, 'utf8');
632
+ }
633
+ catch {
634
+ /* fall through */
635
+ }
636
+ }
637
+ // Resolve relative to this module: src/ultraapp/manager.ts → ../../skills/ultraapp/SKILL.md
638
+ // After build, dist/src/ultraapp/manager.js → ../../../skills/ultraapp/SKILL.md
639
+ const here = path.dirname(fileURLToPath(import.meta.url));
640
+ const candidates = [
641
+ path.resolve(here, '../../skills/ultraapp/SKILL.md'),
642
+ path.resolve(here, '../../../skills/ultraapp/SKILL.md'),
643
+ path.resolve(process.cwd(), 'skills/ultraapp/SKILL.md'),
644
+ ];
645
+ for (const c of candidates) {
646
+ try {
647
+ return fs.readFileSync(c, 'utf8');
648
+ }
649
+ catch {
650
+ /* try next */
651
+ }
652
+ }
653
+ // Test fallback — minimal but functional contract
654
+ return [
655
+ 'You are running the ultraapp interview. Emit one question per turn as a fenced ```question JSON block with',
656
+ '{"question": str, "options": [{"label": str, "value": str}], "recommended": str, "freeformAccepted": bool, "context"?: str}.',
657
+ 'Use <tool name="update_spec">[...JSON Patch...]</tool>, <tool name="extract_metadata">{"ref": str}</tool>,',
658
+ '<tool name="check_completeness">{}</tool>. End with [INTERVIEW: COMPLETE] when done.',
659
+ ].join(' ');
660
+ }
661
+ //# sourceMappingURL=manager.js.map