ultra-builder-pro-cli 0.12.1 → 0.14.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.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/discovery.md +10 -0
- package/CHANGELOG.md +95 -1
- package/README.md +31 -16
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +21 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +10 -6
- package/docs/ARCHITECTURE.md +40 -17
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +2 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -299
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +14 -37
- package/mcp-server/lib/task-expander.test.cjs +80 -164
- package/mcp-server/server.cjs +34 -37
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +391 -17
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +20 -7
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +133 -19
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
|
@@ -15,21 +15,26 @@ const fs = require('node:fs');
|
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
17
|
const { initStateDb } = require('./state-db.cjs');
|
|
18
|
+
const { assertStateAuthority, inspectProjection } = require('./state-authority.cjs');
|
|
18
19
|
const baselines = require('./baseline-workflow.cjs');
|
|
19
20
|
const runtimeState = require('./runtime-state.cjs');
|
|
21
|
+
const ops = require('./state-ops.cjs');
|
|
20
22
|
|
|
21
23
|
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
22
24
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
23
25
|
: path.resolve(__dirname, '..', '..');
|
|
24
|
-
const
|
|
26
|
+
const SOURCE_TEMPLATE = path.join(REPO_ROOT, 'templates', '.ultra');
|
|
27
|
+
const PACKAGED_TEMPLATE = path.join(REPO_ROOT, '.ultra-template');
|
|
28
|
+
const DEFAULT_TEMPLATE = fs.existsSync(SOURCE_TEMPLATE) ? SOURCE_TEMPLATE : PACKAGED_TEMPLATE;
|
|
25
29
|
|
|
26
30
|
class InitProjectError extends Error {
|
|
27
|
-
constructor(code, message, { retriable = false, cause } = {}) {
|
|
31
|
+
constructor(code, message, { retriable = false, cause, details } = {}) {
|
|
28
32
|
super(message);
|
|
29
33
|
this.name = 'InitProjectError';
|
|
30
34
|
this.code = code;
|
|
31
35
|
this.retriable = retriable;
|
|
32
36
|
if (cause) this.cause = cause;
|
|
37
|
+
if (details) this.details = details;
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -97,6 +102,18 @@ function copyTemplate(source, dest) {
|
|
|
97
102
|
return files;
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
function copyMissingTemplate(source, dest) {
|
|
106
|
+
const copied = [];
|
|
107
|
+
for (const rel of walkRelative(source)) {
|
|
108
|
+
const to = path.join(dest, rel);
|
|
109
|
+
if (fs.existsSync(to)) continue;
|
|
110
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
111
|
+
fs.copyFileSync(path.join(source, rel), to);
|
|
112
|
+
copied.push(rel);
|
|
113
|
+
}
|
|
114
|
+
return copied;
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
function validateTasksTemplate(ultraDir) {
|
|
101
118
|
const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
|
|
102
119
|
if (!fs.existsSync(tasksFile)) return;
|
|
@@ -112,15 +129,150 @@ const IGNORED_ADOPTION_ENTRIES = new Set([
|
|
|
112
129
|
'.DS_Store', '.git', '.gitignore', '.ultra', 'node_modules', 'vendor', '.venv', 'venv',
|
|
113
130
|
]);
|
|
114
131
|
|
|
132
|
+
const IGNORED_SCAN_DIRS = new Set([
|
|
133
|
+
...IGNORED_ADOPTION_ENTRIES, 'dist', 'build', 'coverage', '.next', '.turbo', 'target',
|
|
134
|
+
]);
|
|
135
|
+
const MANIFEST_NAMES = new Set([
|
|
136
|
+
'package.json', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod', 'go.work',
|
|
137
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json',
|
|
138
|
+
]);
|
|
139
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
140
|
+
'.c', '.cc', '.cpp', '.cs', '.go', '.java', '.js', '.jsx', '.kt', '.kts', '.mjs',
|
|
141
|
+
'.move', '.php', '.py', '.rb', '.rs', '.sol', '.swift', '.ts', '.tsx', '.vue',
|
|
142
|
+
]);
|
|
143
|
+
const SOURCE_DIR_SEGMENTS = new Set([
|
|
144
|
+
'src', 'app', 'apps', 'lib', 'server', 'backend', 'frontend', 'api', 'cmd', 'internal',
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
function scanRepositoryFiles(rootDir, { maxFiles = 5000, maxDepth = 8 } = {}) {
|
|
148
|
+
const files = [];
|
|
149
|
+
function walk(dir, depth) {
|
|
150
|
+
if (files.length >= maxFiles || depth > maxDepth) return;
|
|
151
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
152
|
+
if (files.length >= maxFiles) break;
|
|
153
|
+
if (entry.name === '.DS_Store') continue;
|
|
154
|
+
const full = path.join(dir, entry.name);
|
|
155
|
+
const rel = path.relative(rootDir, full).split(path.sep).join('/');
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
if (!IGNORED_SCAN_DIRS.has(entry.name) && !entry.name.startsWith('.ultra.backup.')) {
|
|
158
|
+
walk(full, depth + 1);
|
|
159
|
+
}
|
|
160
|
+
} else if (entry.isFile()) files.push(rel);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
walk(rootDir, 0);
|
|
164
|
+
return { files: files.sort(), truncated: files.length >= maxFiles };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function expandWorkspacePattern(rootDir, pattern) {
|
|
168
|
+
const normalized = String(pattern || '').trim().replace(/^['"]|['"]$/g, '').replace(/\\/g, '/');
|
|
169
|
+
if (!normalized) return [];
|
|
170
|
+
if (!normalized.includes('*')) {
|
|
171
|
+
const candidate = path.join(rootDir, normalized);
|
|
172
|
+
return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? [normalized] : [];
|
|
173
|
+
}
|
|
174
|
+
if (!normalized.endsWith('/*') || normalized.slice(0, -2).includes('*')) return [];
|
|
175
|
+
const parentRel = normalized.slice(0, -2);
|
|
176
|
+
const parent = path.join(rootDir, parentRel);
|
|
177
|
+
if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) return [];
|
|
178
|
+
return fs.readdirSync(parent, { withFileTypes: true })
|
|
179
|
+
.filter((entry) => entry.isDirectory())
|
|
180
|
+
.map((entry) => `${parentRel}/${entry.name}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function packageProfile(rootDir) {
|
|
184
|
+
const file = path.join(rootDir, 'package.json');
|
|
185
|
+
if (!fs.existsSync(file)) return { workspacePatterns: [], verificationCommands: [] };
|
|
186
|
+
try {
|
|
187
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
188
|
+
const rawWorkspaces = Array.isArray(pkg.workspaces)
|
|
189
|
+
? pkg.workspaces
|
|
190
|
+
: (Array.isArray(pkg.workspaces?.packages) ? pkg.workspaces.packages : []);
|
|
191
|
+
const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
192
|
+
const verificationCommands = ['build', 'test', 'lint', 'typecheck']
|
|
193
|
+
.filter((name) => typeof scripts[name] === 'string' && scripts[name].trim())
|
|
194
|
+
.map((name) => name === 'test' ? 'npm test' : `npm run ${name}`);
|
|
195
|
+
return { workspacePatterns: rawWorkspaces, verificationCommands };
|
|
196
|
+
} catch {
|
|
197
|
+
return { workspacePatterns: [], verificationCommands: [] };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function classifyRepository(absTarget) {
|
|
202
|
+
const { files, truncated } = scanRepositoryFiles(absTarget);
|
|
203
|
+
const packageData = packageProfile(absTarget);
|
|
204
|
+
const manifestSignals = files.filter((file) => MANIFEST_NAMES.has(path.basename(file))).slice(0, 100);
|
|
205
|
+
const testSignals = files.filter((file) => {
|
|
206
|
+
const lower = file.toLowerCase();
|
|
207
|
+
return /(^|\/)(test|tests|__tests__|spec)(\/|$)/.test(lower)
|
|
208
|
+
|| /\.(test|spec)\.[^.]+$/.test(lower);
|
|
209
|
+
}).slice(0, 100);
|
|
210
|
+
const deploymentSignals = files.filter((file) => {
|
|
211
|
+
const lower = file.toLowerCase();
|
|
212
|
+
return /(^|\/)(dockerfile|railway\.json|railway\.toml|vercel\.json|fly\.toml)$/.test(lower)
|
|
213
|
+
|| lower.startsWith('.github/workflows/')
|
|
214
|
+
|| /(^|\/)docker-compose[^/]*\.ya?ml$/.test(lower);
|
|
215
|
+
}).slice(0, 100);
|
|
216
|
+
const databaseSignals = files.filter((file) => {
|
|
217
|
+
const lower = file.toLowerCase();
|
|
218
|
+
return /(^|\/)(migrations?|prisma|schema)(\/|$)/.test(lower)
|
|
219
|
+
|| /(^|\/)schema\.(sql|prisma)$/.test(lower);
|
|
220
|
+
}).slice(0, 100);
|
|
221
|
+
const sourceSignals = files.filter((file) => {
|
|
222
|
+
const lower = file.toLowerCase();
|
|
223
|
+
if (testSignals.includes(file) || lower.endsWith('.config.js') || lower.endsWith('.config.ts')) return false;
|
|
224
|
+
const segments = file.split('/');
|
|
225
|
+
const ext = path.extname(file);
|
|
226
|
+
if (!SOURCE_EXTENSIONS.has(ext)) return false;
|
|
227
|
+
return segments.some((segment) => SOURCE_DIR_SEGMENTS.has(segment))
|
|
228
|
+
|| /(^|\/)(main|index|server|app)\.[^.]+$/.test(lower);
|
|
229
|
+
}).slice(0, 200);
|
|
230
|
+
const workspaceRoots = [...new Set(packageData.workspacePatterns
|
|
231
|
+
.flatMap((pattern) => expandWorkspacePattern(absTarget, pattern)))]
|
|
232
|
+
.sort();
|
|
233
|
+
const workspaceMarkers = [];
|
|
234
|
+
if (packageData.workspacePatterns.length > 0) workspaceMarkers.push('package.json#workspaces');
|
|
235
|
+
for (const marker of ['pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', 'go.work']) {
|
|
236
|
+
if (files.includes(marker)) workspaceMarkers.push(marker);
|
|
237
|
+
}
|
|
238
|
+
if (workspaceRoots.length === 0 && files.some((file) => /^packages\/[^/]+\/package\.json$/.test(file))) {
|
|
239
|
+
workspaceRoots.push(...files
|
|
240
|
+
.filter((file) => /^packages\/[^/]+\/package\.json$/.test(file))
|
|
241
|
+
.map((file) => path.posix.dirname(file)));
|
|
242
|
+
workspaceRoots.sort();
|
|
243
|
+
}
|
|
244
|
+
const reasons = [];
|
|
245
|
+
if (sourceSignals.length > 0) reasons.push('SOURCE_PRESENT');
|
|
246
|
+
if (testSignals.length > 0) reasons.push('TESTS_PRESENT');
|
|
247
|
+
if (deploymentSignals.length > 0) reasons.push('DEPLOYMENT_PRESENT');
|
|
248
|
+
if (databaseSignals.length > 0) reasons.push('PERSISTED_STATE_PRESENT');
|
|
249
|
+
if (workspaceMarkers.length > 0 || workspaceRoots.length > 0) reasons.push('MONOREPO_PRESENT');
|
|
250
|
+
if (reasons.length === 0) reasons.push('SKELETON_ONLY');
|
|
251
|
+
const mode = sourceSignals.length > 0 || testSignals.length > 0
|
|
252
|
+
|| deploymentSignals.length > 0 || databaseSignals.length > 0
|
|
253
|
+
? 'brownfield' : 'greenfield';
|
|
254
|
+
return {
|
|
255
|
+
mode,
|
|
256
|
+
reasons,
|
|
257
|
+
repository_kind: workspaceMarkers.length > 0 || workspaceRoots.length > 0 ? 'monorepo' : 'single',
|
|
258
|
+
workspace_markers: workspaceMarkers,
|
|
259
|
+
workspace_roots: [...new Set(workspaceRoots)],
|
|
260
|
+
manifest_signals: manifestSignals,
|
|
261
|
+
source_signals: sourceSignals,
|
|
262
|
+
test_signals: testSignals,
|
|
263
|
+
deployment_signals: deploymentSignals,
|
|
264
|
+
database_signals: databaseSignals,
|
|
265
|
+
verification_commands: packageData.verificationCommands,
|
|
266
|
+
scan_truncated: truncated,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
115
270
|
function detectProjectMode(absTarget, requested = 'auto') {
|
|
116
271
|
if (!['auto', 'greenfield', 'brownfield'].includes(requested)) {
|
|
117
272
|
throw new InitProjectError('VALIDATION_ERROR', `unsupported initialization mode: ${requested}`);
|
|
118
273
|
}
|
|
119
274
|
if (requested !== 'auto') return requested;
|
|
120
|
-
|
|
121
|
-
.some((entry) => !IGNORED_ADOPTION_ENTRIES.has(entry.name)
|
|
122
|
-
&& !entry.name.startsWith('.ultra.backup.'));
|
|
123
|
-
return meaningful ? 'brownfield' : 'greenfield';
|
|
275
|
+
return classifyRepository(absTarget).mode;
|
|
124
276
|
}
|
|
125
277
|
|
|
126
278
|
function rollbackInitialization(ultraDir, backupPath) {
|
|
@@ -137,25 +289,144 @@ function rollbackInitialization(ultraDir, backupPath) {
|
|
|
137
289
|
return errors;
|
|
138
290
|
}
|
|
139
291
|
|
|
292
|
+
function projectionMigrationError(projection) {
|
|
293
|
+
const targets = { '4.4': '4.5', '4.5': '12.0' };
|
|
294
|
+
const target = targets[projection.version];
|
|
295
|
+
if (target) {
|
|
296
|
+
return new InitProjectError(
|
|
297
|
+
'LEGACY_STATE_MIGRATION_REQUIRED',
|
|
298
|
+
`state.db has no tasks but ${projection.path} contains ${projection.taskCount} v${projection.version} tasks; `
|
|
299
|
+
+ `run ultra-tools migrate --from=${projection.version} --to=${target} --source-dir <project-root> before resuming initialization`,
|
|
300
|
+
{ details: { projection_path: projection.path, projection_version: projection.version, legacy_task_count: projection.taskCount } },
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return new InitProjectError(
|
|
304
|
+
'STATE_AUTHORITY_CONFLICT',
|
|
305
|
+
`state.db has no tasks but ${projection.path} contains ${projection.taskCount} tasks with unsupported projection version ${projection.version}`,
|
|
306
|
+
{ details: { projection_path: projection.path, projection_version: projection.version, legacy_task_count: projection.taskCount } },
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resumeProject({
|
|
311
|
+
absTarget, ultraDir, source, project_name, project_type, stack, resolvedMode,
|
|
312
|
+
scope, repositoryProfile, projection,
|
|
313
|
+
}) {
|
|
314
|
+
const stateDbPath = path.join(ultraDir, 'state.db');
|
|
315
|
+
const stateExisted = fs.existsSync(stateDbPath);
|
|
316
|
+
let initialized;
|
|
317
|
+
let copiedFiles = [];
|
|
318
|
+
let baselineResult;
|
|
319
|
+
try {
|
|
320
|
+
initialized = initStateDb(stateDbPath);
|
|
321
|
+
const { db } = initialized;
|
|
322
|
+
if (projection && projection.taskCount > 0) assertStateAuthority(db, absTarget);
|
|
323
|
+
copiedFiles = copyMissingTemplate(source, ultraDir);
|
|
324
|
+
validateTasksTemplate(ultraDir);
|
|
325
|
+
baselineResult = baselines.readBaseline(db);
|
|
326
|
+
if (!baselineResult) {
|
|
327
|
+
baselineResult = baselines.startBaseline(db, {
|
|
328
|
+
id: 'project-baseline', project_name, project_type, stack,
|
|
329
|
+
mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
|
|
330
|
+
scope: scope === undefined ? ['.'] : scope,
|
|
331
|
+
classification: repositoryProfile,
|
|
332
|
+
}, { rootDir: absTarget });
|
|
333
|
+
}
|
|
334
|
+
ops.appendEvent(db, {
|
|
335
|
+
type: 'project_resumed',
|
|
336
|
+
payload: {
|
|
337
|
+
project_name, mode: baselineResult.mode, baseline_id: baselineResult.id,
|
|
338
|
+
installed_missing_files: copiedFiles.length,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
|
|
342
|
+
const projectionResult = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
|
|
343
|
+
const projected = projectionResult.jobs.find((item) => item.id === job.id);
|
|
344
|
+
if (!projected || projected.status !== 'completed') {
|
|
345
|
+
throw new Error(projected?.error || 'resume projection did not complete');
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (initialized?.db?.open) initialized.db.close();
|
|
349
|
+
for (const rel of copiedFiles.reverse()) {
|
|
350
|
+
try { fs.rmSync(path.join(ultraDir, rel), { force: true }); } catch { /* best effort */ }
|
|
351
|
+
}
|
|
352
|
+
if (!stateExisted) {
|
|
353
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
354
|
+
try { fs.rmSync(`${stateDbPath}${suffix}`, { force: true }); } catch { /* best effort */ }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (error.code && ['LEGACY_STATE_MIGRATION_REQUIRED', 'STATE_AUTHORITY_CONFLICT', 'STATE_PROJECTION_INVALID'].includes(error.code)) {
|
|
358
|
+
throw new InitProjectError(error.code, error.message, { cause: error, details: error.details });
|
|
359
|
+
}
|
|
360
|
+
throw new InitProjectError(
|
|
361
|
+
'IO_ERROR', `project resume failed: ${error.message}; existing .ultra assets were preserved`,
|
|
362
|
+
{ retriable: true, cause: error },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
initialized.db.close();
|
|
366
|
+
const result = {
|
|
367
|
+
created_path: ultraDir,
|
|
368
|
+
state_db_path: stateDbPath,
|
|
369
|
+
status: 'resumed',
|
|
370
|
+
mode: resolvedMode,
|
|
371
|
+
baseline: {
|
|
372
|
+
id: baselineResult.id,
|
|
373
|
+
mode: baselineResult.mode,
|
|
374
|
+
status: baselineResult.status,
|
|
375
|
+
repository_revision: baselineResult.repository_revision,
|
|
376
|
+
scope: baselineResult.scope,
|
|
377
|
+
},
|
|
378
|
+
copied_files: copiedFiles,
|
|
379
|
+
repository_profile: repositoryProfile,
|
|
380
|
+
};
|
|
381
|
+
if (initialized.backup_path) result.migration_backup_path = initialized.backup_path;
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
140
385
|
function initProject({
|
|
141
386
|
target_dir,
|
|
142
387
|
project_name,
|
|
143
388
|
project_type,
|
|
144
389
|
stack,
|
|
145
390
|
mode = 'auto',
|
|
391
|
+
scope,
|
|
392
|
+
resume = false,
|
|
146
393
|
overwrite = false,
|
|
147
394
|
source_template,
|
|
148
395
|
} = {}) {
|
|
149
396
|
if (typeof project_name !== 'string' || project_name.length === 0) {
|
|
150
397
|
throw new InitProjectError('VALIDATION_ERROR', 'project_name must be non-empty');
|
|
151
398
|
}
|
|
399
|
+
if (resume && overwrite) {
|
|
400
|
+
throw new InitProjectError('VALIDATION_ERROR', 'resume and overwrite are mutually exclusive');
|
|
401
|
+
}
|
|
152
402
|
|
|
153
403
|
const absTarget = resolveTargetDir(target_dir);
|
|
154
404
|
ensureTargetDir(absTarget);
|
|
155
|
-
const
|
|
405
|
+
const repositoryProfile = classifyRepository(absTarget);
|
|
406
|
+
const resolvedMode = mode === 'auto' ? repositoryProfile.mode : detectProjectMode(absTarget, mode);
|
|
407
|
+
repositoryProfile.detected_mode = repositoryProfile.mode;
|
|
408
|
+
repositoryProfile.mode = resolvedMode;
|
|
156
409
|
const source = ensureTemplate(source_template);
|
|
157
410
|
const ultraDir = path.join(absTarget, '.ultra');
|
|
158
411
|
|
|
412
|
+
if (fs.existsSync(ultraDir) && resume) {
|
|
413
|
+
let projection;
|
|
414
|
+
try { projection = inspectProjection(absTarget); }
|
|
415
|
+
catch (error) {
|
|
416
|
+
throw new InitProjectError(error.code || 'STATE_PROJECTION_INVALID', error.message, {
|
|
417
|
+
cause: error, details: error.details,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (projection && projection.taskCount > 0
|
|
421
|
+
&& !fs.existsSync(path.join(ultraDir, 'state.db'))) {
|
|
422
|
+
throw projectionMigrationError(projection);
|
|
423
|
+
}
|
|
424
|
+
return resumeProject({
|
|
425
|
+
absTarget, ultraDir, source, project_name, project_type, stack, resolvedMode,
|
|
426
|
+
scope, repositoryProfile, projection,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
159
430
|
let status = 'created';
|
|
160
431
|
let backupPath;
|
|
161
432
|
if (fs.existsSync(ultraDir)) {
|
|
@@ -179,8 +450,16 @@ function initProject({
|
|
|
179
450
|
try {
|
|
180
451
|
baselineResult = baselines.startBaseline(db, {
|
|
181
452
|
id: 'project-baseline', project_name, project_type, stack,
|
|
182
|
-
mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
|
|
453
|
+
mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
|
|
454
|
+
scope: scope === undefined ? ['.'] : scope,
|
|
455
|
+
classification: repositoryProfile,
|
|
183
456
|
}, { rootDir: absTarget });
|
|
457
|
+
ops.appendEvent(db, {
|
|
458
|
+
type: 'project_initialized',
|
|
459
|
+
payload: {
|
|
460
|
+
project_name, mode: resolvedMode, baseline_id: baselineResult.id,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
184
463
|
const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
|
|
185
464
|
const projection = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
|
|
186
465
|
const projected = projection.jobs.find((item) => item.id === job.id);
|
|
@@ -213,11 +492,15 @@ function initProject({
|
|
|
213
492
|
mode: baselineResult.mode,
|
|
214
493
|
status: baselineResult.status,
|
|
215
494
|
repository_revision: baselineResult.repository_revision,
|
|
495
|
+
scope: baselineResult.scope,
|
|
216
496
|
},
|
|
217
497
|
copied_files: copiedFiles,
|
|
498
|
+
repository_profile: repositoryProfile,
|
|
218
499
|
};
|
|
219
500
|
if (backupPath) result.backup_path = backupPath;
|
|
220
501
|
return result;
|
|
221
502
|
}
|
|
222
503
|
|
|
223
|
-
module.exports = {
|
|
504
|
+
module.exports = {
|
|
505
|
+
initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode, classifyRepository,
|
|
506
|
+
};
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// mocking network I/O.
|
|
3
|
+
// Validate and normalize a task graph derived from a PRD by the current host.
|
|
4
|
+
// The MCP runtime is deliberately deterministic: model judgment stays with
|
|
5
|
+
// the host that already owns the user interaction, while this module owns the
|
|
6
|
+
// machine contract before state.db is mutated.
|
|
8
7
|
|
|
9
8
|
const Ajv = require('ajv/dist/2020');
|
|
10
|
-
const {
|
|
11
|
-
buildSystemPrompt,
|
|
12
|
-
buildUserPrompt,
|
|
13
|
-
} = require('../../orchestrator/planner/prd-prompt.cjs');
|
|
14
|
-
|
|
15
9
|
const TASK_LIST_SCHEMA = Object.freeze({
|
|
16
10
|
type: 'object',
|
|
17
11
|
required: ['tasks'],
|
|
@@ -48,39 +42,22 @@ class PrdParseError extends Error {
|
|
|
48
42
|
}
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
if (!
|
|
53
|
-
throw new PrdParseError('NO_INPUT', '
|
|
54
|
-
}
|
|
55
|
-
if (!llmClient || typeof llmClient.completeJson !== 'function') {
|
|
56
|
-
throw new PrdParseError('NO_LLM_CLIENT', 'llmClient.completeJson is required');
|
|
45
|
+
function parsePrd(tasks, { tag, changeId } = {}) {
|
|
46
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
47
|
+
throw new PrdParseError('NO_INPUT', 'at least one host-derived task is required');
|
|
57
48
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const user = buildUserPrompt(prdText);
|
|
61
|
-
|
|
62
|
-
let reply;
|
|
63
|
-
try {
|
|
64
|
-
reply = await llmClient.completeJson({ system, user });
|
|
65
|
-
} catch (err) {
|
|
66
|
-
throw new PrdParseError(
|
|
67
|
-
'PARSE_FAILED',
|
|
68
|
-
`LLM call failed: ${err.message}`,
|
|
69
|
-
err,
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (!validateTaskList(reply.json)) {
|
|
49
|
+
const input = { tasks };
|
|
50
|
+
if (!validateTaskList(input)) {
|
|
74
51
|
throw new PrdParseError(
|
|
75
52
|
'PARSE_FAILED',
|
|
76
|
-
`
|
|
53
|
+
`task graph failed schema: ${ajv.errorsText(validateTaskList.errors)}`,
|
|
77
54
|
);
|
|
78
55
|
}
|
|
79
56
|
|
|
80
57
|
const ids = new Set();
|
|
81
|
-
const
|
|
58
|
+
const normalized = tasks.map((t) => {
|
|
82
59
|
if (ids.has(t.id)) {
|
|
83
|
-
throw new PrdParseError('PARSE_FAILED', `duplicate task id "${t.id}" in
|
|
60
|
+
throw new PrdParseError('PARSE_FAILED', `duplicate task id "${t.id}" in task graph`);
|
|
84
61
|
}
|
|
85
62
|
ids.add(t.id);
|
|
86
63
|
return {
|
|
@@ -92,15 +69,11 @@ async function parsePrd(prdText, { llmClient, tag } = {}) {
|
|
|
92
69
|
deps: Array.isArray(t.deps) ? t.deps : [],
|
|
93
70
|
files_modified: Array.isArray(t.files_modified) ? t.files_modified : [],
|
|
94
71
|
tag: tag || null,
|
|
72
|
+
change_id: changeId || null,
|
|
95
73
|
};
|
|
96
74
|
});
|
|
97
75
|
|
|
98
|
-
return {
|
|
99
|
-
tasks,
|
|
100
|
-
usage: reply.usage || {},
|
|
101
|
-
model: reply.model,
|
|
102
|
-
provider: reply.provider,
|
|
103
|
-
};
|
|
76
|
+
return { tasks: normalized };
|
|
104
77
|
}
|
|
105
78
|
|
|
106
79
|
module.exports = {
|