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
@@ -38,6 +38,7 @@ const {
38
38
  readProjectBreadcrumb,
39
39
  renderProjectBreadcrumb,
40
40
  } = require('./lib/project-breadcrumb.cjs');
41
+ const sessionRunner = require('../orchestrator/session-runner.cjs');
41
42
 
42
43
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
43
44
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
@@ -153,11 +154,6 @@ function buildAjv() {
153
154
  return ajv;
154
155
  }
155
156
 
156
- function mintSessionId() {
157
- const { randomUUID } = require('node:crypto');
158
- return `sess-${randomUUID().slice(0, 8)}`;
159
- }
160
-
161
157
  async function dispatchTool(name, input, db, ctx = {}) {
162
158
  switch (name) {
163
159
  case 'task.create': {
@@ -207,40 +203,51 @@ async function dispatchTool(name, input, db, ctx = {}) {
207
203
  return ops.subscribeEventsSince(db, input || {});
208
204
  }
209
205
  case 'session.spawn': {
210
- // admission + takeover logic; actual git worktree + child process is
211
- // delegated to orchestrator/session-runner (Phase 4.5.1). This tool
212
- // records intent in state.db and returns the paths the runner should use.
213
- const rootDir = ctx.rootDir || process.cwd();
214
- const verdict = ops.admissionCheck(db, input.task_id);
215
- if (!verdict.can_spawn && !input.takeover) {
216
- const err = new Error(`active session exists for task ${input.task_id}; pass takeover=true or choose resume/abandon`);
217
- err.code = 'ADMISSION_DENIED';
218
- throw err;
219
- }
220
- if (!verdict.can_spawn && input.takeover && verdict.conflict) {
221
- ops.updateSession(db, verdict.conflict.sid, { status: 'crashed' });
206
+ if (ctx.sessionId) {
207
+ const error = new Error(
208
+ `worker session ${ctx.sessionId} already owns an isolated worktree; `
209
+ + 'resume that session instead of recursively spawning another one',
210
+ );
211
+ error.code = 'NESTED_SESSION_FORBIDDEN';
212
+ throw error;
222
213
  }
223
- const sid = mintSessionId();
224
- const worktreeBase = input.worktree_base || path.join(rootDir, '.ultra', 'worktrees');
225
- const worktree_path = path.join(worktreeBase, sid);
226
- const artifact_dir = path.join(rootDir, '.ultra', 'sessions', sid);
227
- const session = ops.createSession(db, {
228
- sid,
214
+ const rootDir = ctx.rootDir || process.cwd();
215
+ const handle = sessionRunner.spawnSession({
216
+ db,
217
+ repoRoot: rootDir,
229
218
  task_id: input.task_id,
230
219
  runtime: input.runtime,
231
- worktree_path,
232
- artifact_dir,
220
+ takeover: Boolean(input.takeover),
221
+ worktree_base: input.worktree_base,
233
222
  });
234
223
  return {
235
- sid,
236
- worktree_path,
237
- artifact_dir,
238
- lease_expires_at: session.lease_expires_at,
224
+ sid: handle.sid,
225
+ worktree_path: handle.worktree_path,
226
+ artifact_dir: handle.artifact_dir,
227
+ lease_expires_at: handle.lease_expires_at,
228
+ worktree_created: true,
239
229
  };
240
230
  }
241
231
  case 'session.close': {
242
- ops.updateSession(db, input.sid, { status: input.status });
243
- return { ok: true };
232
+ if (ctx.sessionId) {
233
+ const error = new Error(
234
+ ctx.sessionId === input.sid
235
+ ? `worker session ${ctx.sessionId} is supervised by its parent; save evidence and exit so the parent can settle the lease`
236
+ : `worker session ${ctx.sessionId} cannot close unrelated session ${input.sid}`,
237
+ );
238
+ error.code = ctx.sessionId === input.sid
239
+ ? 'WORKER_SESSION_PARENT_OWNED'
240
+ : 'SESSION_SCOPE_VIOLATION';
241
+ throw error;
242
+ }
243
+ const closed = sessionRunner.closeSession(
244
+ { db, repoRoot: ctx.rootDir || process.cwd(), sid: input.sid },
245
+ { status: input.status, remove_worktree: input.remove_worktree === true },
246
+ );
247
+ return {
248
+ ok: true,
249
+ worktree_preserved: closed.worktree_preserved,
250
+ };
244
251
  }
245
252
  case 'session.get': {
246
253
  const session = ops.readSession(db, input.sid);
@@ -252,6 +259,11 @@ async function dispatchTool(name, input, db, ctx = {}) {
252
259
  return { session };
253
260
  }
254
261
  case 'session.admission_check': {
262
+ sessionRunner.assertSessionTaskReady(
263
+ db,
264
+ ctx.rootDir || process.cwd(),
265
+ input.task_id,
266
+ );
255
267
  return ops.admissionCheck(db, input.task_id);
256
268
  }
257
269
  case 'session.list': {
@@ -633,7 +645,13 @@ function errorResponse(code, message, retriable = false, details = undefined) {
633
645
  };
634
646
  }
635
647
 
636
- function startServer({ dbPath, rootDir, projectOnWrite = true, project = projector.projectAll }) {
648
+ function startServer({
649
+ dbPath,
650
+ rootDir,
651
+ sessionId = null,
652
+ projectOnWrite = true,
653
+ project = projector.projectAll,
654
+ }) {
637
655
  let db = null;
638
656
  const getDb = () => {
639
657
  if (!db) db = initStateDb(dbPath).db;
@@ -703,7 +721,12 @@ function startServer({ dbPath, rootDir, projectOnWrite = true, project = project
703
721
  try {
704
722
  toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
705
723
  if (toolDb) assertStateAuthority(toolDb, rootDir);
706
- result = await dispatchTool(name, args, toolDb, { rootDir, projector: project });
724
+ result = await dispatchTool(
725
+ name,
726
+ args,
727
+ toolDb,
728
+ { rootDir, sessionId, projector: project },
729
+ );
707
730
  } catch (err) {
708
731
  const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');
709
732
  toolError = code;
@@ -768,7 +791,8 @@ async function main() {
768
791
  const rootDir = process.env.UBP_ROOT_DIR
769
792
  ? path.resolve(process.env.UBP_ROOT_DIR)
770
793
  : path.resolve('.');
771
- const handle = startServer({ dbPath, rootDir });
794
+ const sessionId = process.env.UBP_SESSION_ID || null;
795
+ const handle = startServer({ dbPath, rootDir, sessionId });
772
796
  const transport = new StdioServerTransport();
773
797
  await handle.server.connect(transport);
774
798
  const cleanup = () => handle.close().finally(() => process.exit(0));
@@ -40,6 +40,25 @@ function autoMerge({ repoRoot, worktreePath, baseBranch = 'main', sid, task_id =
40
40
  if (!repoRoot) throw new Error('autoMerge: repoRoot required');
41
41
  if (!worktreePath) throw new Error('autoMerge: worktreePath required');
42
42
 
43
+ try {
44
+ const status = runGit(['status', '--porcelain=v1'], { cwd: worktreePath });
45
+ if (status) {
46
+ return {
47
+ merged: false,
48
+ reason: 'uncommitted_changes',
49
+ session_sha: null,
50
+ base_sha: null,
51
+ };
52
+ }
53
+ } catch (err) {
54
+ return {
55
+ merged: false,
56
+ reason: 'worktree_status_failed',
57
+ session_sha: null,
58
+ base_sha: null,
59
+ };
60
+ }
61
+
43
62
  let sessionSha;
44
63
  let baseSha;
45
64
  try {
@@ -32,6 +32,26 @@ function routeTask(task, availableRuntimes) {
32
32
  return d.action === 'spawn_agent' ? d.runtime : null;
33
33
  }
34
34
 
35
+ function taskDependenciesReady(db, task) {
36
+ return (task.deps || []).every((dependencyId) => {
37
+ const dependency = ops.readTask(db, dependencyId);
38
+ return Boolean(dependency && ['completed', 'expanded'].includes(dependency.status));
39
+ });
40
+ }
41
+
42
+ function hasActiveFileConflict(db, task) {
43
+ const declared = new Set(task.files_modified || []);
44
+ if (declared.size === 0) return false;
45
+ return ops.listActiveSessions(db).some((session) => {
46
+ if (!session.task_id || session.task_id === task.id) return false;
47
+ const activeTask = ops.readTask(db, session.task_id);
48
+ return Boolean(
49
+ activeTask
50
+ && (activeTask.files_modified || []).some((file) => declared.has(file)),
51
+ );
52
+ });
53
+ }
54
+
35
55
  function maintainState({ db, repoRoot, project = projector.projectAll } = {}) {
36
56
  if (!db) throw new Error('maintainState: db required');
37
57
  if (!repoRoot) throw new Error('maintainState: repoRoot required');
@@ -60,6 +80,18 @@ function runDaemon({
60
80
  if (!Array.isArray(runtimes) || runtimes.length === 0) {
61
81
  throw new Error('runDaemon: runtimes array required');
62
82
  }
83
+ if (typeof command !== 'string' || command.trim() === '') {
84
+ const error = new Error(
85
+ 'runDaemon: an explicit executable command is required; refusing to reserve empty sessions',
86
+ );
87
+ error.code = 'ORCHESTRATOR_COMMAND_REQUIRED';
88
+ throw error;
89
+ }
90
+ if (!Array.isArray(commandArgs)) {
91
+ const error = new Error('runDaemon: commandArgs must be an array');
92
+ error.code = 'ORCHESTRATOR_COMMAND_INVALID';
93
+ throw error;
94
+ }
63
95
 
64
96
  let stopped = false;
65
97
  const children = [];
@@ -75,6 +107,54 @@ function runDaemon({
75
107
  bootRecovery = { recovered: [], count: 0, error: error.message };
76
108
  }
77
109
 
110
+ function settleExecution(handle, task) {
111
+ let settled = false;
112
+ const stopHeartbeat = runner.attachHeartbeat(db, handle.sid);
113
+ const settle = (rawCode, signal, spawnError = null) => {
114
+ if (settled) return;
115
+ settled = true;
116
+ stopHeartbeat();
117
+ const code = rawCode === null ? -1 : rawCode;
118
+ const success = code === 0;
119
+ // A caller may intentionally close the DB while leaving child processes
120
+ // alive during shutdown. The next boot recovery owns those stale leases.
121
+ if (!db.open) return;
122
+ const liveSession = ops.readSession(db, handle.sid);
123
+ // Takeover or an explicit close already owns the terminal transition.
124
+ // The late child exit must not block the replacement task/session.
125
+ if (!liveSession || liveSession.status !== 'running') return;
126
+ try {
127
+ if (!success) {
128
+ try { ops.patchTask(db, task.id, { status: 'blocked' }); }
129
+ catch (error) {
130
+ runtimeState.recordIncident(db, {
131
+ code: 'ORCHESTRATOR_TASK_BLOCK_FAILED', severity: 'error', retryable: true,
132
+ message: error.message, source_kind: 'task', source_id: task.id,
133
+ });
134
+ }
135
+ ops.recordTaskFailure(db, task.id, {
136
+ reason: spawnError
137
+ ? `worker spawn or runtime error: ${spawnError.message}`
138
+ : `runtime process exited with code ${code}${signal ? ` (${signal})` : ''}`,
139
+ session_id: handle.sid,
140
+ });
141
+ }
142
+ runner.closeSession(
143
+ { db, repoRoot, sid: handle.sid },
144
+ { status: success ? 'completed' : 'crashed', remove_worktree: false },
145
+ );
146
+ } catch (error) {
147
+ if (onError) onError(error);
148
+ else process.stderr.write(`orchestrator settle error: ${error.message}\n`);
149
+ }
150
+ };
151
+ handle.process.once('exit', settle);
152
+ handle.process.once('error', (error) => settle(-1, null, error));
153
+ if (handle.process.exitCode !== null && handle.process.exitCode !== undefined) {
154
+ setImmediate(() => settle(handle.process.exitCode, handle.process.signalCode || null));
155
+ }
156
+ }
157
+
78
158
  function tick() {
79
159
  if (stopped) return;
80
160
  try {
@@ -101,6 +181,17 @@ function runDaemon({
101
181
  }
102
182
  for (const task of pending) {
103
183
  if (stopped) return;
184
+ if (task.stale) continue;
185
+ if (!taskDependenciesReady(db, task)) continue;
186
+ if (hasActiveFileConflict(db, task)) continue;
187
+ try {
188
+ runner.assertSessionTaskReady(db, repoRoot, task.id);
189
+ } catch (error) {
190
+ // A normal workflow gate is a durable pending state, not a daemon
191
+ // failure and not an error to repeat on every poll.
192
+ if (!runner.isExpectedExecutionGate(error) && onError) onError(error);
193
+ continue;
194
+ }
104
195
  // admissionCheck catches both live-session conflicts and tripped breakers.
105
196
  let verdict;
106
197
  try { verdict = ops.admissionCheck(db, task.id); }
@@ -118,6 +209,17 @@ function runDaemon({
118
209
  command,
119
210
  args: commandArgs,
120
211
  });
212
+ handle.task_id = task.id;
213
+ try {
214
+ ops.patchTask(db, task.id, { status: 'in_progress' });
215
+ } catch (error) {
216
+ runner.closeSession(
217
+ { db, repoRoot, sid: handle.sid },
218
+ { status: 'crashed', remove_worktree: false },
219
+ );
220
+ throw error;
221
+ }
222
+ settleExecution(handle, task);
121
223
  children.push(handle);
122
224
  } catch (err) {
123
225
  if (onError) onError(err);
@@ -147,4 +249,6 @@ module.exports = {
147
249
  runDaemon,
148
250
  routeTask,
149
251
  maintainState,
252
+ taskDependenciesReady,
253
+ hasActiveFileConflict,
150
254
  };