ultra-builder-pro-cli 0.15.0 → 0.17.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 (71) hide show
  1. package/.ultra-template/specs/research-distillate.md +4 -3
  2. package/CHANGELOG.md +120 -1
  3. package/README.md +67 -30
  4. package/adapters/_shared/codex-assets.cjs +10 -5
  5. package/adapters/_shared/handbook.cjs +3 -2
  6. package/adapters/_shared/interaction-contract.cjs +61 -0
  7. package/adapters/claude.js +1 -1
  8. package/adapters/opencode.js +1 -1
  9. package/bin/orchestrator.js +175 -19
  10. package/commands/ultra-change.md +1 -1
  11. package/commands/ultra-deliver.md +3 -3
  12. package/commands/ultra-init.md +1 -1
  13. package/commands/ultra-plan.md +1 -1
  14. package/commands/ultra-research.md +2 -2
  15. package/commands/ultra-review.md +10 -0
  16. package/commands/ultra-status.md +1 -1
  17. package/commands/ultra-test.md +1 -1
  18. package/commands/ultra-think.md +2 -2
  19. package/docs/AGENT-CONTEXT.md +38 -18
  20. package/docs/ARCHITECTURE.md +91 -13
  21. package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
  22. package/docs/PLAN.zh-CN.md +19 -12
  23. package/docs/ROADMAP.md +5 -3
  24. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  25. package/docs/STATE-DB-ACCESS-POLICY.md +8 -6
  26. package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
  27. package/docs/WORKFLOW-LIFECYCLE.md +179 -39
  28. package/hooks/pre_stop_check.py +3 -2
  29. package/mcp-server/lib/baseline-workflow.cjs +84 -16
  30. package/mcp-server/lib/change-workflow.cjs +107 -39
  31. package/mcp-server/lib/context-spine.cjs +215 -87
  32. package/mcp-server/lib/decision-dialogue.cjs +713 -0
  33. package/mcp-server/lib/doctor.cjs +9 -0
  34. package/mcp-server/lib/git-bootstrap.cjs +240 -0
  35. package/mcp-server/lib/init-project.cjs +178 -41
  36. package/mcp-server/lib/project-breadcrumb.cjs +23 -14
  37. package/mcp-server/lib/state-db.cjs +279 -9
  38. package/mcp-server/lib/state-ops.cjs +64 -1
  39. package/mcp-server/lib/workflow-state.cjs +313 -205
  40. package/mcp-server/server.cjs +112 -34
  41. package/orchestrator/auto-merge.cjs +19 -0
  42. package/orchestrator/daemon.cjs +104 -0
  43. package/orchestrator/parallel-orchestrator.cjs +371 -49
  44. package/orchestrator/session-runner.cjs +354 -25
  45. package/package.json +2 -1
  46. package/skills/ultra-change/SKILL.md +69 -65
  47. package/skills/ultra-deliver/SKILL.md +37 -58
  48. package/skills/ultra-dev/SKILL.md +55 -49
  49. package/skills/ultra-doctor/SKILL.md +44 -49
  50. package/skills/ultra-init/SKILL.md +78 -100
  51. package/skills/ultra-plan/SKILL.md +78 -79
  52. package/skills/ultra-research/SKILL.md +99 -98
  53. package/skills/ultra-review/SKILL.md +38 -47
  54. package/skills/ultra-review/references/review-modes.md +5 -3
  55. package/skills/ultra-review/references/unified-schema.md +26 -6
  56. package/skills/ultra-review/scripts/review_wait.py +12 -0
  57. package/skills/ultra-status/SKILL.md +34 -40
  58. package/skills/ultra-test/SKILL.md +61 -48
  59. package/skills/ultra-think/SKILL.md +47 -47
  60. package/skills/ultra-think/references/decision-dialogue.md +106 -0
  61. package/spec/cli-protocol.md +46 -2
  62. package/spec/mcp-tools.yaml +444 -29
  63. package/spec/schemas/delivery-report.v1.schema.json +1 -46
  64. package/spec/schemas/mcp-tools.schema.json +1 -1
  65. package/spec/schemas/state-db.sql +66 -2
  66. package/spec/schemas/test-report.v1.schema.json +45 -14
  67. package/ultra-tools/cli.cjs +1 -1
  68. package/ultra-tools/commands/session.cjs +10 -3
  69. package/ultra-tools/commands/status.cjs +34 -14
  70. package/ultra-tools/commands/system.cjs +2 -2
  71. package/ultra-tools/commands/task.cjs +3 -0
@@ -32,11 +32,13 @@ const baselines = require('./lib/baseline-workflow.cjs');
32
32
  const changes = require('./lib/change-workflow.cjs');
33
33
  const runtimeState = require('./lib/runtime-state.cjs');
34
34
  const workflows = require('./lib/workflow-state.cjs');
35
+ const decisions = require('./lib/decision-dialogue.cjs');
35
36
  const doctor = require('./lib/doctor.cjs');
36
37
  const {
37
38
  readProjectBreadcrumb,
38
39
  renderProjectBreadcrumb,
39
40
  } = require('./lib/project-breadcrumb.cjs');
41
+ const sessionRunner = require('../orchestrator/session-runner.cjs');
40
42
 
41
43
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
42
44
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
@@ -101,12 +103,25 @@ const WORKFLOW_TOOLS = Object.freeze([
101
103
  'workflow.complete',
102
104
  ]);
103
105
 
106
+ const DECISION_TOOLS = Object.freeze([
107
+ 'decision.thread_start',
108
+ 'decision.get',
109
+ 'decision.list',
110
+ 'decision.open',
111
+ 'decision.resolve',
112
+ 'decision.delegate',
113
+ 'decision.defer',
114
+ 'decision.supersede',
115
+ 'decision.checkpoint',
116
+ ]);
117
+
104
118
  const SYSTEM_TOOLS = Object.freeze([
105
119
  'system.doctor',
106
120
  ]);
107
121
 
108
122
  const REGISTERED_TOOLS = Object.freeze([
109
123
  ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...BASELINE_TOOLS, ...CHANGE_TOOLS,
124
+ ...DECISION_TOOLS,
110
125
  ...WORKFLOW_TOOLS,
111
126
  ...SYSTEM_TOOLS,
112
127
  ]);
@@ -123,6 +138,8 @@ const MUTATING_TOOLS = new Set([
123
138
  'baseline.start', 'baseline.record', 'baseline.converge',
124
139
  'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
125
140
  'change.learning_propose', 'change.learning_resolve',
141
+ 'decision.thread_start', 'decision.open', 'decision.resolve', 'decision.delegate',
142
+ 'decision.defer', 'decision.supersede', 'decision.checkpoint',
126
143
  'workflow.start', 'workflow.step', 'workflow.complete',
127
144
  ]);
128
145
 
@@ -137,11 +154,6 @@ function buildAjv() {
137
154
  return ajv;
138
155
  }
139
156
 
140
- function mintSessionId() {
141
- const { randomUUID } = require('node:crypto');
142
- return `sess-${randomUUID().slice(0, 8)}`;
143
- }
144
-
145
157
  async function dispatchTool(name, input, db, ctx = {}) {
146
158
  switch (name) {
147
159
  case 'task.create': {
@@ -191,40 +203,51 @@ async function dispatchTool(name, input, db, ctx = {}) {
191
203
  return ops.subscribeEventsSince(db, input || {});
192
204
  }
193
205
  case 'session.spawn': {
194
- // admission + takeover logic; actual git worktree + child process is
195
- // delegated to orchestrator/session-runner (Phase 4.5.1). This tool
196
- // records intent in state.db and returns the paths the runner should use.
197
- const rootDir = ctx.rootDir || process.cwd();
198
- const verdict = ops.admissionCheck(db, input.task_id);
199
- if (!verdict.can_spawn && !input.takeover) {
200
- const err = new Error(`active session exists for task ${input.task_id}; pass takeover=true or choose resume/abandon`);
201
- err.code = 'ADMISSION_DENIED';
202
- throw err;
203
- }
204
- if (!verdict.can_spawn && input.takeover && verdict.conflict) {
205
- 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;
206
213
  }
207
- const sid = mintSessionId();
208
- const worktreeBase = input.worktree_base || path.join(rootDir, '.ultra', 'worktrees');
209
- const worktree_path = path.join(worktreeBase, sid);
210
- const artifact_dir = path.join(rootDir, '.ultra', 'sessions', sid);
211
- const session = ops.createSession(db, {
212
- sid,
214
+ const rootDir = ctx.rootDir || process.cwd();
215
+ const handle = sessionRunner.spawnSession({
216
+ db,
217
+ repoRoot: rootDir,
213
218
  task_id: input.task_id,
214
219
  runtime: input.runtime,
215
- worktree_path,
216
- artifact_dir,
220
+ takeover: Boolean(input.takeover),
221
+ worktree_base: input.worktree_base,
217
222
  });
218
223
  return {
219
- sid,
220
- worktree_path,
221
- artifact_dir,
222
- 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,
223
229
  };
224
230
  }
225
231
  case 'session.close': {
226
- ops.updateSession(db, input.sid, { status: input.status });
227
- 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
+ };
228
251
  }
229
252
  case 'session.get': {
230
253
  const session = ops.readSession(db, input.sid);
@@ -236,6 +259,11 @@ async function dispatchTool(name, input, db, ctx = {}) {
236
259
  return { session };
237
260
  }
238
261
  case 'session.admission_check': {
262
+ sessionRunner.assertSessionTaskReady(
263
+ db,
264
+ ctx.rootDir || process.cwd(),
265
+ input.task_id,
266
+ );
239
267
  return ops.admissionCheck(db, input.task_id);
240
268
  }
241
269
  case 'session.list': {
@@ -517,6 +545,44 @@ async function dispatchTool(name, input, db, ctx = {}) {
517
545
  case 'change.archive': {
518
546
  return changes.archiveChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
519
547
  }
548
+ case 'decision.thread_start': {
549
+ return { thread: decisions.startDecisionThread(db, input) };
550
+ }
551
+ case 'decision.get': {
552
+ const thread = decisions.readDecisionThread(db, input.id);
553
+ if (!thread) {
554
+ const error = new Error(`decision thread ${input.id} not found`);
555
+ error.code = 'DECISION_THREAD_NOT_FOUND';
556
+ throw error;
557
+ }
558
+ return { thread };
559
+ }
560
+ case 'decision.list': {
561
+ const threads = decisions.listDecisionThreads(db, input || {});
562
+ return { threads, count: threads.length };
563
+ }
564
+ case 'decision.open': {
565
+ return { thread: decisions.openDecision(db, input) };
566
+ }
567
+ case 'decision.resolve': {
568
+ return { thread: decisions.resolveDecision(db, input) };
569
+ }
570
+ case 'decision.delegate': {
571
+ return { thread: decisions.delegateDecision(db, input) };
572
+ }
573
+ case 'decision.defer': {
574
+ return { thread: decisions.deferDecision(db, input) };
575
+ }
576
+ case 'decision.supersede': {
577
+ return { thread: decisions.supersedeDecision(db, input) };
578
+ }
579
+ case 'decision.checkpoint': {
580
+ return {
581
+ thread: decisions.checkpointDecisionThread(
582
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
583
+ ),
584
+ };
585
+ }
520
586
  case 'workflow.start': {
521
587
  return {
522
588
  workflow: workflows.startWorkflow(
@@ -579,7 +645,13 @@ function errorResponse(code, message, retriable = false, details = undefined) {
579
645
  };
580
646
  }
581
647
 
582
- 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
+ }) {
583
655
  let db = null;
584
656
  const getDb = () => {
585
657
  if (!db) db = initStateDb(dbPath).db;
@@ -649,7 +721,12 @@ function startServer({ dbPath, rootDir, projectOnWrite = true, project = project
649
721
  try {
650
722
  toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
651
723
  if (toolDb) assertStateAuthority(toolDb, rootDir);
652
- 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
+ );
653
730
  } catch (err) {
654
731
  const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');
655
732
  toolError = code;
@@ -714,7 +791,8 @@ async function main() {
714
791
  const rootDir = process.env.UBP_ROOT_DIR
715
792
  ? path.resolve(process.env.UBP_ROOT_DIR)
716
793
  : path.resolve('.');
717
- const handle = startServer({ dbPath, rootDir });
794
+ const sessionId = process.env.UBP_SESSION_ID || null;
795
+ const handle = startServer({ dbPath, rootDir, sessionId });
718
796
  const transport = new StdioServerTransport();
719
797
  await handle.server.connect(transport);
720
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
  };