ultra-builder-pro-cli 0.16.0 → 0.17.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 (66) hide show
  1. package/.ultra-template/reports/templates/delivery-report.json +17 -0
  2. package/.ultra-template/reports/templates/test-report.json +18 -0
  3. package/.ultra-template/specs/research-distillate.md +4 -3
  4. package/CHANGELOG.md +95 -0
  5. package/README.md +54 -24
  6. package/adapters/_shared/codex-assets.cjs +10 -5
  7. package/adapters/_shared/handbook.cjs +1 -1
  8. package/adapters/_shared/interaction-contract.cjs +61 -0
  9. package/adapters/claude.js +1 -1
  10. package/adapters/opencode.js +1 -1
  11. package/bin/orchestrator.js +175 -19
  12. package/commands/ultra-change.md +1 -1
  13. package/commands/ultra-deliver.md +3 -3
  14. package/commands/ultra-init.md +1 -1
  15. package/commands/ultra-plan.md +1 -1
  16. package/commands/ultra-research.md +2 -2
  17. package/commands/ultra-review.md +10 -0
  18. package/commands/ultra-status.md +1 -1
  19. package/commands/ultra-test.md +1 -1
  20. package/docs/AGENT-CONTEXT.md +11 -8
  21. package/docs/ARCHITECTURE.md +67 -9
  22. package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
  23. package/docs/PLAN.zh-CN.md +19 -13
  24. package/docs/RUNTIME-COMPAT-MATRIX.md +2 -2
  25. package/docs/WORKFLOW-LIFECYCLE.md +123 -59
  26. package/hooks/pre_stop_check.py +3 -2
  27. package/mcp-server/lib/baseline-workflow.cjs +84 -16
  28. package/mcp-server/lib/change-workflow.cjs +55 -35
  29. package/mcp-server/lib/context-spine.cjs +171 -105
  30. package/mcp-server/lib/git-bootstrap.cjs +240 -0
  31. package/mcp-server/lib/init-project.cjs +178 -41
  32. package/mcp-server/lib/project-breadcrumb.cjs +14 -14
  33. package/mcp-server/lib/state-db.cjs +257 -10
  34. package/mcp-server/lib/state-ops.cjs +64 -1
  35. package/mcp-server/lib/workflow-state.cjs +300 -205
  36. package/mcp-server/server.cjs +58 -34
  37. package/orchestrator/auto-merge.cjs +19 -0
  38. package/orchestrator/daemon.cjs +104 -0
  39. package/orchestrator/parallel-orchestrator.cjs +371 -49
  40. package/orchestrator/session-runner.cjs +354 -25
  41. package/package.json +2 -1
  42. package/skills/ultra-change/SKILL.md +69 -94
  43. package/skills/ultra-deliver/SKILL.md +37 -65
  44. package/skills/ultra-dev/SKILL.md +50 -52
  45. package/skills/ultra-doctor/SKILL.md +34 -49
  46. package/skills/ultra-init/SKILL.md +78 -105
  47. package/skills/ultra-plan/SKILL.md +78 -99
  48. package/skills/ultra-research/SKILL.md +99 -113
  49. package/skills/ultra-review/SKILL.md +38 -53
  50. package/skills/ultra-review/references/review-modes.md +5 -3
  51. package/skills/ultra-review/references/unified-schema.md +26 -6
  52. package/skills/ultra-review/scripts/review_wait.py +12 -0
  53. package/skills/ultra-status/SKILL.md +34 -44
  54. package/skills/ultra-test/SKILL.md +61 -54
  55. package/skills/ultra-think/SKILL.md +37 -51
  56. package/skills/ultra-think/references/decision-dialogue.md +87 -111
  57. package/spec/cli-protocol.md +35 -0
  58. package/spec/mcp-tools.yaml +145 -28
  59. package/spec/schemas/delivery-report.v1.schema.json +1 -46
  60. package/spec/schemas/state-db.sql +8 -2
  61. package/spec/schemas/test-report.v1.schema.json +45 -14
  62. package/ultra-tools/cli.cjs +1 -1
  63. package/ultra-tools/commands/session.cjs +10 -3
  64. package/ultra-tools/commands/status.cjs +19 -14
  65. package/ultra-tools/commands/system.cjs +2 -2
  66. package/ultra-tools/commands/task.cjs +3 -0
@@ -3,15 +3,17 @@
3
3
  // Phase 8B.2 — Parallel orchestrator.
4
4
  //
5
5
  // Consumes an execution-plan.json (built by Phase 8A.4a plan-builder) and
6
- // drives per-wave session spawning. Each wave is either parallel (batch
7
- // Promise.all on proc exit) or serial (await per task) based on the
6
+ // drives resumable per-wave session spawning. Each wave is either parallel
7
+ // (batch Promise.all on proc exit) or serial (await per task) based on the
8
8
  // file-overlap detection already done by plan-builder.
9
9
  //
10
10
  // Responsibilities:
11
11
  // • load plan (object or path)
12
- // • for each wave: evaluate dispatch-rules per task → spawn sessions via
13
- // session-runner → wait for exit → flip session status → task status
14
- // • emit wave_started / wave_completed / plan_completed events
12
+ // • for each ready wave: evaluate dispatch-rules per task → spawn sessions
13
+ // via session-runner → wait for transport exit → preserve task-gate state
14
+ // • stop at the first non-converged wave and resume safely on the next call
15
+ // • emit wave_started / wave_paused|wave_completed and
16
+ // plan_paused|plan_completed events
15
17
  //
16
18
  // Not responsible for:
17
19
  // • circuit breaker (Phase 5.2 recordTaskFailure owns that path)
@@ -19,12 +21,16 @@
19
21
  // • merge back (Phase 8B.4 layers on top of closeSession)
20
22
 
21
23
  const fs = require('node:fs');
24
+ const { isDeepStrictEqual } = require('node:util');
22
25
 
23
26
  const planStore = require('../mcp-server/lib/plan-store.cjs');
24
27
  const ops = require('../mcp-server/lib/state-ops.cjs');
28
+ const workflows = require('../mcp-server/lib/workflow-state.cjs');
25
29
  const runner = require('./session-runner.cjs');
26
30
  const dispatchRules = require('./dispatch-rules.cjs');
27
31
 
32
+ const TERMINAL_TASK_STATUSES = new Set(['completed', 'expanded']);
33
+
28
34
  function resolvePlan({ plan, planPath, repoRoot }) {
29
35
  if (plan) return plan;
30
36
  if (planPath) return JSON.parse(fs.readFileSync(planPath, 'utf8'));
@@ -37,44 +43,251 @@ function resolvePlan({ plan, planPath, repoRoot }) {
37
43
  return loaded;
38
44
  }
39
45
 
46
+ function planError(code, message, details = null) {
47
+ const error = new Error(message);
48
+ error.code = code;
49
+ if (details) error.details = details;
50
+ return error;
51
+ }
52
+
53
+ function validatePlanShape(plan) {
54
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
55
+ throw planError('PLAN_INVALID', 'execution plan must be an object');
56
+ }
57
+ if (plan.schema_version !== undefined && plan.schema_version !== '1.0') {
58
+ throw planError(
59
+ 'PLAN_INVALID',
60
+ `unsupported execution plan schema_version: ${String(plan.schema_version)}`,
61
+ );
62
+ }
63
+ if (plan.change_id !== undefined && plan.change_id !== null
64
+ && (typeof plan.change_id !== 'string' || plan.change_id.trim() === '')) {
65
+ throw planError('PLAN_INVALID', 'execution plan change_id must be null or a non-empty string');
66
+ }
67
+ if (!Array.isArray(plan.waves) || plan.waves.length === 0) {
68
+ throw planError('PLAN_INVALID', 'execution plan must contain at least one dependency wave');
69
+ }
70
+ if (plan.cycles !== undefined && !Array.isArray(plan.cycles)) {
71
+ throw planError('PLAN_INVALID', 'execution plan cycles must be an array');
72
+ }
73
+ if (Array.isArray(plan.cycles) && plan.cycles.length > 0) {
74
+ throw planError('PLAN_INVALID', 'execution plan contains dependency cycles', {
75
+ cycles: plan.cycles,
76
+ });
77
+ }
78
+
79
+ const waveIds = new Set();
80
+ const taskIds = new Set();
81
+ for (const [index, wave] of plan.waves.entries()) {
82
+ if (!wave || typeof wave !== 'object' || Array.isArray(wave)) {
83
+ throw planError('PLAN_INVALID', `execution plan wave ${index + 1} must be an object`);
84
+ }
85
+ if ((typeof wave.id !== 'string' && typeof wave.id !== 'number')
86
+ || String(wave.id).trim() === '') {
87
+ throw planError('PLAN_INVALID', `execution plan wave ${index + 1} requires an id`);
88
+ }
89
+ const waveKey = String(wave.id);
90
+ if (waveIds.has(waveKey)) {
91
+ throw planError('PLAN_INVALID', `execution plan contains duplicate wave id ${waveKey}`);
92
+ }
93
+ waveIds.add(waveKey);
94
+ if (!Array.isArray(wave.tasks) || wave.tasks.length === 0) {
95
+ throw planError('PLAN_INVALID', `execution plan wave ${waveKey} must contain tasks`);
96
+ }
97
+ if (typeof wave.parallel !== 'boolean') {
98
+ throw planError('PLAN_INVALID', `execution plan wave ${waveKey} requires parallel boolean`);
99
+ }
100
+ for (const taskId of wave.tasks) {
101
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
102
+ throw planError('PLAN_INVALID', `execution plan wave ${waveKey} has an invalid task id`);
103
+ }
104
+ if (taskIds.has(taskId)) {
105
+ throw planError('PLAN_INVALID', `execution plan contains duplicate task ${taskId}`);
106
+ }
107
+ taskIds.add(taskId);
108
+ }
109
+ }
110
+ return plan;
111
+ }
112
+
113
+ function assertPlanAuthority(db, repoRoot, plan) {
114
+ const taskIds = plan.waves.flatMap((wave) => wave.tasks);
115
+ const taskRows = taskIds.map((id) => ops.readTask(db, id)).filter(Boolean);
116
+ const taskChangeIds = new Set(taskRows.map((task) => task.change_id).filter(Boolean));
117
+
118
+ if (!plan.change_id) {
119
+ if (taskChangeIds.size > 0) {
120
+ throw planError(
121
+ 'PLAN_NOT_COMPLETED',
122
+ 'change-owned tasks require a change-bound, completed current execution plan',
123
+ );
124
+ }
125
+ return { mode: 'legacy-unbound', workflow_id: null };
126
+ }
127
+ if (taskChangeIds.size > 1 || (taskChangeIds.size === 1 && !taskChangeIds.has(plan.change_id))) {
128
+ throw planError(
129
+ 'PLAN_CHANGE_MISMATCH',
130
+ `execution plan ${plan.change_id} contains tasks owned by another change`,
131
+ { task_change_ids: [...taskChangeIds].sort() },
132
+ );
133
+ }
134
+
135
+ const completedRuns = workflows.listWorkflows(
136
+ db,
137
+ { kind: 'plan', status: 'completed', change_id: plan.change_id, limit: 100 },
138
+ { rootDir: repoRoot },
139
+ );
140
+ if (completedRuns.length === 0) {
141
+ throw planError(
142
+ 'PLAN_NOT_COMPLETED',
143
+ `change ${plan.change_id} has no completed plan workflow`,
144
+ );
145
+ }
146
+
147
+ let currentPlan;
148
+ try {
149
+ currentPlan = workflows.assertCurrentPlan(db, plan.change_id, repoRoot);
150
+ } catch (error) {
151
+ throw planError(
152
+ error.code === 'WORKFLOW_PLAN_NOT_COMPLETED' ? 'PLAN_NOT_COMPLETED' : 'PLAN_STALE',
153
+ error.message,
154
+ { authority_code: error.code || null },
155
+ );
156
+ }
157
+ const expected = planStore.buildPlan(
158
+ currentPlan.tasks,
159
+ { changeId: plan.change_id },
160
+ );
161
+ if (!isDeepStrictEqual(plan, expected)) {
162
+ throw planError(
163
+ 'PLAN_STALE',
164
+ `execution plan for ${plan.change_id} does not match the current DB task graph`,
165
+ );
166
+ }
167
+ return { mode: 'current-change', workflow_id: currentPlan.plan.id };
168
+ }
169
+
40
170
  function awaitExit(proc) {
41
171
  return new Promise((resolve) => {
42
- if (!proc) { resolve({ code: 0, signal: null }); return; }
172
+ if (!proc) {
173
+ resolve({ code: -1, signal: null, error: new Error('worker process was not created') });
174
+ return;
175
+ }
43
176
  if (proc.exitCode !== null && proc.exitCode !== undefined) {
44
- resolve({ code: proc.exitCode, signal: proc.signalCode || null });
177
+ resolve({ code: proc.exitCode, signal: proc.signalCode || null, error: null });
45
178
  return;
46
179
  }
47
- proc.on('exit', (code, signal) => resolve({ code: code === null ? -1 : code, signal }));
180
+ let settled = false;
181
+ const settle = (result) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ proc.removeListener('exit', onExit);
185
+ proc.removeListener('error', onError);
186
+ resolve(result);
187
+ };
188
+ const onExit = (code, signal) => settle({
189
+ code: code === null ? -1 : code,
190
+ signal,
191
+ error: null,
192
+ });
193
+ const onError = (error) => settle({ code: -1, signal: null, error });
194
+ proc.once('exit', onExit);
195
+ proc.once('error', onError);
48
196
  });
49
197
  }
50
198
 
51
- function finalizeSession(db, handle, code, ctx = {}) {
199
+ function failureReason(execution) {
200
+ if (execution.error) return `worker spawn or runtime error: ${execution.error.message}`;
201
+ return `runtime process exited with code ${execution.code}`
202
+ + `${execution.signal ? ` (${execution.signal})` : ''}`;
203
+ }
204
+
205
+ function recordExecutionFailure(db, taskId, {
206
+ sessionId = null,
207
+ reason,
208
+ } = {}) {
209
+ const current = ops.readTask(db, taskId);
210
+ if (current && !TERMINAL_TASK_STATUSES.has(current.status) && current.status !== 'blocked') {
211
+ try { ops.patchTask(db, taskId, { status: 'blocked' }); }
212
+ catch (error) {
213
+ process.stderr.write(
214
+ `parallel-orchestrator: patchTask(${taskId}→blocked) failed: ${error.message}\n`,
215
+ );
216
+ }
217
+ }
218
+ try {
219
+ ops.recordTaskFailure(db, taskId, { reason, session_id: sessionId });
220
+ } catch (error) {
221
+ process.stderr.write(
222
+ `parallel-orchestrator: recordTaskFailure(${taskId}) failed: ${error.message}\n`,
223
+ );
224
+ }
225
+ }
226
+
227
+ function finalizeSession(db, handle, execution, ctx = {}) {
228
+ const { code } = execution;
52
229
  const sessionStatus = code === 0 ? 'completed' : 'crashed';
53
- // Task status: success → completed; failure → revert to pending so the
54
- // recovery layer (Phase 5.1) / circuit breaker can take over next tick.
55
- const taskStatus = code === 0 ? 'completed' : 'pending';
56
- try { ops.patchTask(db, handle.task_id, { status: taskStatus }); }
57
- catch (err) {
58
- process.stderr.write(`parallel-orchestrator: patchTask(${handle.task_id}→${taskStatus}) failed: ${err.message}\n`);
230
+ const liveSession = ops.readSession(db, handle.sid);
231
+ if (!liveSession || liveSession.status !== 'running') {
232
+ return {
233
+ sid: handle.sid,
234
+ task_id: handle.task_id,
235
+ code,
236
+ signal: execution.signal || null,
237
+ error: execution.error?.message || null,
238
+ status: liveSession?.status || 'missing',
239
+ task_status: ops.readTask(db, handle.task_id)?.status || null,
240
+ worktree_preserved: Boolean(
241
+ liveSession?.worktree_path && fs.existsSync(liveSession.worktree_path),
242
+ ),
243
+ merge: undefined,
244
+ close_error: null,
245
+ };
246
+ }
247
+ if (code !== 0) {
248
+ recordExecutionFailure(db, handle.task_id, {
249
+ sessionId: handle.sid,
250
+ reason: failureReason(execution),
251
+ });
59
252
  }
60
- // closeSession flips session row status, removes worktree, and (opt-in)
61
- // runs auto-merge. For crashed sessions we skip auto-merge — there's
62
- // nothing to merge back from a broken run.
253
+
254
+ // Process exit is transport evidence, not Ultra task completion. The worker
255
+ // must converge the task through the normal dev/test/review authority before
256
+ // an explicit auto-merge may integrate it.
257
+ let task = ops.readTask(db, handle.task_id);
258
+ const taskConverged = Boolean(task && TERMINAL_TASK_STATUSES.has(task.status));
63
259
  let merge;
260
+ let worktreePreserved = true;
261
+ let closeError = null;
64
262
  try {
65
263
  const closeResult = runner.closeSession(
66
264
  { db, repoRoot: ctx.repoRoot, sid: handle.sid },
67
265
  {
68
266
  status: sessionStatus,
69
- autoMerge: !!ctx.autoMerge && sessionStatus === 'completed',
267
+ autoMerge: !!ctx.autoMerge && sessionStatus === 'completed' && taskConverged,
70
268
  mergeBaseBranch: ctx.mergeBaseBranch || 'main',
71
269
  },
72
270
  );
73
271
  merge = closeResult.merge;
272
+ worktreePreserved = closeResult.worktree_preserved;
74
273
  } catch (err) {
274
+ closeError = err.message;
75
275
  process.stderr.write(`parallel-orchestrator: closeSession(${handle.sid}) failed: ${err.message}\n`);
276
+ if (ctx.onError) ctx.onError(err);
76
277
  }
77
- return { sid: handle.sid, task_id: handle.task_id, code, status: sessionStatus, merge };
278
+ task = ops.readTask(db, handle.task_id);
279
+ return {
280
+ sid: handle.sid,
281
+ task_id: handle.task_id,
282
+ code,
283
+ signal: execution.signal || null,
284
+ error: execution.error?.message || null,
285
+ status: sessionStatus,
286
+ task_status: task?.status || null,
287
+ worktree_preserved: worktreePreserved,
288
+ merge,
289
+ close_error: closeError,
290
+ };
78
291
  }
79
292
 
80
293
  async function runTask(db, repoRoot, task, wave, ctx) {
@@ -102,54 +315,116 @@ async function runTask(db, repoRoot, task, wave, ctx) {
102
315
  });
103
316
  handle.task_id = task.id;
104
317
  } catch (err) {
318
+ if (runner.isExpectedExecutionGate(err)) {
319
+ return {
320
+ sid: null,
321
+ task_id: task.id,
322
+ code: -4,
323
+ status: 'authority_blocked',
324
+ authority_code: err.code,
325
+ error: err.message,
326
+ };
327
+ }
105
328
  if (onError) onError(err);
329
+ recordExecutionFailure(db, task.id, {
330
+ reason: `worker session could not be created: ${err.message}`,
331
+ });
106
332
  return { sid: null, task_id: task.id, code: -3, status: 'spawn_failed' };
107
333
  }
108
334
 
109
- // Required transition: pending → in_progress before the child's exit may
110
- // flip it to completed. STATUS_TRANSITIONS forbids pending → completed.
111
- try { ops.patchTask(db, task.id, { status: 'in_progress' }); }
335
+ // The worker may converge very quickly, so re-read before transitioning and
336
+ // never downgrade a task that already reached a terminal state.
337
+ try {
338
+ if (ops.readTask(db, task.id)?.status === 'pending') {
339
+ ops.patchTask(db, task.id, { status: 'in_progress' });
340
+ }
341
+ }
112
342
  catch (err) {
113
- process.stderr.write(`parallel-orchestrator: patchTask(${task.id}→in_progress) failed: ${err.message}\n`);
343
+ const latest = ops.readTask(db, task.id);
344
+ if (!latest || !TERMINAL_TASK_STATUSES.has(latest.status)) {
345
+ try { if (handle.process && !handle.process.killed) handle.process.kill('SIGTERM'); }
346
+ catch (_) { /* best effort */ }
347
+ recordExecutionFailure(db, task.id, {
348
+ sessionId: handle.sid,
349
+ reason: `task could not enter in_progress: ${err.message}`,
350
+ });
351
+ return finalizeSession(
352
+ db,
353
+ handle,
354
+ { code: -1, signal: 'SIGTERM', error: err },
355
+ { ...ctx, repoRoot },
356
+ );
357
+ }
114
358
  }
115
359
 
116
- const { code } = await awaitExit(handle.process);
117
- return finalizeSession(db, handle, code, { ...ctx, repoRoot });
360
+ const execution = await awaitExit(handle.process);
361
+ return finalizeSession(db, handle, execution, { ...ctx, repoRoot });
118
362
  }
119
363
 
120
- async function runWave(db, repoRoot, wave, ctx) {
121
- ops.appendEvent(db, {
122
- type: 'wave_started',
123
- payload: { wave_id: wave.id, tasks: wave.tasks, parallel: wave.parallel },
364
+ function taskDependenciesReady(db, task) {
365
+ return (task.deps || []).every((dependencyId) => {
366
+ const dependency = ops.readTask(db, dependencyId);
367
+ return Boolean(dependency && TERMINAL_TASK_STATUSES.has(dependency.status));
124
368
  });
369
+ }
370
+
371
+ async function runWave(db, repoRoot, wave, ctx) {
372
+ const taskRows = wave.tasks.map((id) => ({ id, task: ops.readTask(db, id) }));
373
+ const missingTaskIds = taskRows
374
+ .filter((entry) => !entry.task)
375
+ .map((entry) => entry.id);
376
+ if (ctx.onError) {
377
+ for (const id of missingTaskIds) {
378
+ ctx.onError(new Error(`task ${id} not found in DB; wave cannot converge`));
379
+ }
380
+ }
381
+ const tasks = taskRows.map((entry) => entry.task).filter(Boolean);
382
+ const runnable = tasks.filter((task) => (
383
+ task.status === 'pending'
384
+ && !task.stale
385
+ && taskDependenciesReady(db, task)
386
+ ));
125
387
 
126
- const tasks = wave.tasks
127
- .map((id) => ops.readTask(db, id))
128
- .filter((t) => {
129
- if (!t && ctx.onError) ctx.onError(new Error(`task not found in db (skipped)`));
130
- return !!t;
388
+ if (runnable.length > 0) {
389
+ ops.appendEvent(db, {
390
+ type: 'wave_started',
391
+ payload: {
392
+ wave_id: wave.id,
393
+ tasks: runnable.map((task) => task.id),
394
+ parallel: wave.parallel,
395
+ },
131
396
  });
397
+ }
132
398
 
133
399
  let results;
134
- if (wave.parallel && tasks.length > 1) {
135
- results = await Promise.all(tasks.map((task) => runTask(db, repoRoot, task, wave, ctx)));
400
+ if (wave.parallel && runnable.length > 1) {
401
+ results = await Promise.all(runnable.map((task) => runTask(db, repoRoot, task, wave, ctx)));
136
402
  } else {
137
403
  results = [];
138
- for (const task of tasks) {
404
+ for (const task of runnable) {
139
405
  results.push(await runTask(db, repoRoot, task, wave, ctx));
140
406
  }
141
407
  }
142
408
 
409
+ const finalTasks = tasks.map((task) => ops.readTask(db, task.id)).filter(Boolean);
410
+ const complete = missingTaskIds.length === 0
411
+ && finalTasks.length === tasks.length
412
+ && finalTasks.every((task) => TERMINAL_TASK_STATUSES.has(task.status));
413
+ const awaiting = finalTasks
414
+ .filter((task) => !TERMINAL_TASK_STATUSES.has(task.status))
415
+ .map((task) => ({ id: task.id, status: task.status, stale: Boolean(task.stale) }))
416
+ .concat(missingTaskIds.map((id) => ({ id, status: 'missing', stale: false })));
143
417
  ops.appendEvent(db, {
144
- type: 'wave_completed',
418
+ type: complete ? 'wave_completed' : 'wave_paused',
145
419
  payload: {
146
420
  wave_id: wave.id,
147
- completed: results.filter((r) => r.status === 'completed').length,
148
- crashed: results.filter((r) => r.status === 'crashed').length,
421
+ executions_succeeded: results.filter((r) => r.status === 'completed').length,
422
+ executions_crashed: results.filter((r) => r.status === 'crashed').length,
149
423
  deferred: results.filter((r) => r.status === 'defer' || r.status === 'block').length,
424
+ awaiting_workflow_gates: awaiting,
150
425
  },
151
426
  });
152
- return results;
427
+ return { results, complete, awaiting };
153
428
  }
154
429
 
155
430
  async function runPlan({
@@ -164,32 +439,79 @@ async function runPlan({
164
439
  if (!Array.isArray(runtimes) || runtimes.length === 0) {
165
440
  throw new Error('runPlan: runtimes array required');
166
441
  }
167
- const resolved = resolvePlan({ plan, planPath, repoRoot });
442
+ if (typeof command !== 'string' || command.trim() === '') {
443
+ const error = new Error(
444
+ 'runPlan: an explicit executable command is required; refusing to reserve empty sessions',
445
+ );
446
+ error.code = 'ORCHESTRATOR_COMMAND_REQUIRED';
447
+ throw error;
448
+ }
449
+ if (!Array.isArray(commandArgs)) {
450
+ const error = new Error('runPlan: commandArgs must be an array');
451
+ error.code = 'ORCHESTRATOR_COMMAND_INVALID';
452
+ throw error;
453
+ }
454
+ const resolved = validatePlanShape(resolvePlan({ plan, planPath, repoRoot }));
455
+ const planAuthority = assertPlanAuthority(db, repoRoot, resolved);
168
456
 
169
457
  const ctx = {
170
458
  runtimes, command, commandArgs, commandArgsFor, onError,
171
459
  autoMerge, mergeBaseBranch,
172
460
  };
173
461
  const allResults = [];
462
+ let completedWaves = 0;
463
+ let pausedWave = null;
174
464
  for (const wave of resolved.waves) {
175
- const results = await runWave(db, repoRoot, wave, ctx);
176
- allResults.push(...results);
465
+ const outcome = await runWave(db, repoRoot, wave, ctx);
466
+ allResults.push(...outcome.results);
467
+ if (!outcome.complete) {
468
+ pausedWave = { id: wave.id, awaiting: outcome.awaiting };
469
+ break;
470
+ }
471
+ completedWaves += 1;
177
472
  }
178
473
 
474
+ const planTaskIds = [...new Set(resolved.waves.flatMap((wave) => wave.tasks))];
475
+ const awaitingWorkflowGates = planTaskIds
476
+ .map((id) => ops.readTask(db, id) || { id, status: 'missing', stale: false })
477
+ .filter((task) => !TERMINAL_TASK_STATUSES.has(task.status))
478
+ .map((task) => ({ id: task.id, status: task.status, stale: Boolean(task.stale) }));
479
+ const completed = awaitingWorkflowGates.length === 0;
179
480
  ops.appendEvent(db, {
180
- type: 'plan_completed',
481
+ type: completed ? 'plan_completed' : 'plan_paused',
181
482
  payload: {
182
483
  waves: resolved.waves.length,
183
- completed: allResults.filter((r) => r.status === 'completed').length,
484
+ waves_completed: completedWaves,
485
+ paused_wave_id: pausedWave?.id || null,
486
+ executions_succeeded: allResults.filter((r) => r.status === 'completed').length,
184
487
  crashed: allResults.filter((r) => r.status === 'crashed').length,
488
+ awaiting_workflow_gates: awaitingWorkflowGates,
185
489
  },
186
490
  });
187
491
 
188
- return { waves: resolved.waves.length, results: allResults };
492
+ return {
493
+ status: completed ? 'completed' : 'paused',
494
+ waves: resolved.waves.length,
495
+ waves_completed: completedWaves,
496
+ paused_wave_id: pausedWave?.id || null,
497
+ plan_workflow_id: planAuthority.workflow_id,
498
+ awaiting_workflow_gates: awaitingWorkflowGates,
499
+ results: allResults,
500
+ };
189
501
  }
190
502
 
191
503
  module.exports = {
192
504
  runPlan,
193
505
  // exposed for targeted tests
194
- _internal: { runWave, runTask, resolvePlan, awaitExit, finalizeSession },
506
+ _internal: {
507
+ runWave,
508
+ runTask,
509
+ resolvePlan,
510
+ validatePlanShape,
511
+ assertPlanAuthority,
512
+ awaitExit,
513
+ finalizeSession,
514
+ taskDependenciesReady,
515
+ recordExecutionFailure,
516
+ },
195
517
  };