ultra-builder-pro-cli 0.11.0 → 0.13.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/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +41 -228
- package/.ultra-template/specs/product.md +44 -200
- package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
- package/.ultra-template/tasks/tasks.json +6 -0
- package/CHANGELOG.md +94 -1
- package/README.md +37 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +5 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +10 -2
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/opencode.test.cjs +36 -1
- package/commands/ultra-init.md +3 -3
- package/docs/AGENT-CONTEXT.md +17 -8
- package/docs/ARCHITECTURE.md +51 -8
- package/docs/PLAN.zh-CN.md +9 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
- package/hooks/README.md +4 -3
- package/hooks/adapters/codex.py +1 -1
- package/hooks/adapters/kimi.py +1 -1
- package/hooks/context_spine.py +153 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- 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 +781 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
- package/mcp-server/lib/change-workflow.cjs +256 -85
- package/mcp-server/lib/change-workflow.test.cjs +436 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +20 -0
- package/mcp-server/lib/doctor.cjs +28 -1
- package/mcp-server/lib/doctor.test.cjs +63 -1
- package/mcp-server/lib/init-project.cjs +334 -16
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +202 -21
- package/mcp-server/lib/state-db.test.cjs +140 -3
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +62 -6
- package/mcp-server/tests/init-project.test.cjs +216 -9
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +478 -3
- package/mcp-server/tests/state-db.test.cjs +23 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +32 -13
- package/skills/ultra-deliver/SKILL.md +19 -5
- package/skills/ultra-dev/SKILL.md +8 -2
- package/skills/ultra-doctor/SKILL.md +30 -9
- package/skills/ultra-init/SKILL.md +107 -66
- package/skills/ultra-init/references/brownfield-adoption.md +93 -0
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-research/SKILL.md +17 -5
- package/skills/ultra-status/SKILL.md +15 -6
- package/spec/cli-protocol.md +15 -1
- package/spec/mcp-tools.yaml +316 -11
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +44 -2
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +29 -2
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +11 -2
- package/ultra-tools/status-command.test.cjs +49 -0
- package/ultra-tools/system-command.test.cjs +256 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Implementation for MCP tool `task.init_project` and the CLI fallback
|
|
4
|
-
// `ultra-tools init`. Copies the bundled templates/.ultra/ skeleton
|
|
5
|
-
// target project root
|
|
4
|
+
// `ultra-tools task init-project`. Copies the bundled templates/.ultra/ skeleton
|
|
5
|
+
// to the target project root and records project metadata in authoritative state.
|
|
6
6
|
//
|
|
7
7
|
// Error contract matches spec/mcp-tools.yaml#task.init_project:
|
|
8
8
|
// ULTRA_DIR_EXISTS | TEMPLATE_MISSING | TARGET_NOT_DIR | IO_ERROR
|
|
@@ -15,19 +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');
|
|
19
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
20
|
+
const runtimeState = require('./runtime-state.cjs');
|
|
21
|
+
const ops = require('./state-ops.cjs');
|
|
18
22
|
|
|
19
23
|
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
20
24
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
21
25
|
: path.resolve(__dirname, '..', '..');
|
|
22
|
-
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;
|
|
23
29
|
|
|
24
30
|
class InitProjectError extends Error {
|
|
25
|
-
constructor(code, message, { retriable = false, cause } = {}) {
|
|
31
|
+
constructor(code, message, { retriable = false, cause, details } = {}) {
|
|
26
32
|
super(message);
|
|
27
33
|
this.name = 'InitProjectError';
|
|
28
34
|
this.code = code;
|
|
29
35
|
this.retriable = retriable;
|
|
30
36
|
if (cause) this.cause = cause;
|
|
37
|
+
if (details) this.details = details;
|
|
31
38
|
}
|
|
32
39
|
}
|
|
33
40
|
|
|
@@ -95,20 +102,177 @@ function copyTemplate(source, dest) {
|
|
|
95
102
|
return files;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
function
|
|
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
|
+
|
|
117
|
+
function validateTasksTemplate(ultraDir) {
|
|
99
118
|
const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
|
|
100
119
|
if (!fs.existsSync(tasksFile)) return;
|
|
101
120
|
let data;
|
|
102
121
|
try { data = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); }
|
|
103
122
|
catch (err) { throw new InitProjectError('IO_ERROR', `tasks.json malformed: ${err.message}`, { cause: err }); }
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.tasks)) {
|
|
124
|
+
throw new InitProjectError('IO_ERROR', 'tasks.json template must contain a tasks array');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const IGNORED_ADOPTION_ENTRIES = new Set([
|
|
129
|
+
'.DS_Store', '.git', '.gitignore', '.ultra', 'node_modules', 'vendor', '.venv', 'venv',
|
|
130
|
+
]);
|
|
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
|
+
|
|
270
|
+
function detectProjectMode(absTarget, requested = 'auto') {
|
|
271
|
+
if (!['auto', 'greenfield', 'brownfield'].includes(requested)) {
|
|
272
|
+
throw new InitProjectError('VALIDATION_ERROR', `unsupported initialization mode: ${requested}`);
|
|
273
|
+
}
|
|
274
|
+
if (requested !== 'auto') return requested;
|
|
275
|
+
return classifyRepository(absTarget).mode;
|
|
112
276
|
}
|
|
113
277
|
|
|
114
278
|
function rollbackInitialization(ultraDir, backupPath) {
|
|
@@ -125,23 +289,144 @@ function rollbackInitialization(ultraDir, backupPath) {
|
|
|
125
289
|
return errors;
|
|
126
290
|
}
|
|
127
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
|
+
|
|
128
385
|
function initProject({
|
|
129
386
|
target_dir,
|
|
130
387
|
project_name,
|
|
131
388
|
project_type,
|
|
132
389
|
stack,
|
|
390
|
+
mode = 'auto',
|
|
391
|
+
scope,
|
|
392
|
+
resume = false,
|
|
133
393
|
overwrite = false,
|
|
134
394
|
source_template,
|
|
135
395
|
} = {}) {
|
|
136
396
|
if (typeof project_name !== 'string' || project_name.length === 0) {
|
|
137
397
|
throw new InitProjectError('VALIDATION_ERROR', 'project_name must be non-empty');
|
|
138
398
|
}
|
|
399
|
+
if (resume && overwrite) {
|
|
400
|
+
throw new InitProjectError('VALIDATION_ERROR', 'resume and overwrite are mutually exclusive');
|
|
401
|
+
}
|
|
139
402
|
|
|
140
403
|
const absTarget = resolveTargetDir(target_dir);
|
|
141
404
|
ensureTargetDir(absTarget);
|
|
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;
|
|
142
409
|
const source = ensureTemplate(source_template);
|
|
143
410
|
const ultraDir = path.join(absTarget, '.ultra');
|
|
144
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
|
+
|
|
145
430
|
let status = 'created';
|
|
146
431
|
let backupPath;
|
|
147
432
|
if (fs.existsSync(ultraDir)) {
|
|
@@ -155,13 +440,35 @@ function initProject({
|
|
|
155
440
|
}
|
|
156
441
|
|
|
157
442
|
let copiedFiles;
|
|
443
|
+
let baselineResult;
|
|
158
444
|
try {
|
|
159
445
|
fs.mkdirSync(ultraDir, { recursive: true });
|
|
160
446
|
copiedFiles = copyTemplate(source, ultraDir);
|
|
161
|
-
|
|
447
|
+
validateTasksTemplate(ultraDir);
|
|
162
448
|
const stateDbPath = path.join(ultraDir, 'state.db');
|
|
163
449
|
const { db } = initStateDb(stateDbPath);
|
|
164
|
-
|
|
450
|
+
try {
|
|
451
|
+
baselineResult = baselines.startBaseline(db, {
|
|
452
|
+
id: 'project-baseline', project_name, project_type, stack,
|
|
453
|
+
mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
|
|
454
|
+
scope: scope === undefined ? ['.'] : scope,
|
|
455
|
+
classification: repositoryProfile,
|
|
456
|
+
}, { rootDir: absTarget });
|
|
457
|
+
ops.appendEvent(db, {
|
|
458
|
+
type: 'project_initialized',
|
|
459
|
+
payload: {
|
|
460
|
+
project_name, mode: resolvedMode, baseline_id: baselineResult.id,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
|
|
464
|
+
const projection = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
|
|
465
|
+
const projected = projection.jobs.find((item) => item.id === job.id);
|
|
466
|
+
if (!projected || projected.status !== 'completed') {
|
|
467
|
+
throw new Error(projected?.error || 'initial projection did not complete');
|
|
468
|
+
}
|
|
469
|
+
} finally {
|
|
470
|
+
db.close();
|
|
471
|
+
}
|
|
165
472
|
} catch (err) {
|
|
166
473
|
const rollbackErrors = rollbackInitialization(ultraDir, backupPath);
|
|
167
474
|
const rollbackNote = rollbackErrors.length > 0
|
|
@@ -179,10 +486,21 @@ function initProject({
|
|
|
179
486
|
created_path: ultraDir,
|
|
180
487
|
state_db_path: stateDbPath,
|
|
181
488
|
status,
|
|
489
|
+
mode: resolvedMode,
|
|
490
|
+
baseline: {
|
|
491
|
+
id: baselineResult.id,
|
|
492
|
+
mode: baselineResult.mode,
|
|
493
|
+
status: baselineResult.status,
|
|
494
|
+
repository_revision: baselineResult.repository_revision,
|
|
495
|
+
scope: baselineResult.scope,
|
|
496
|
+
},
|
|
182
497
|
copied_files: copiedFiles,
|
|
498
|
+
repository_profile: repositoryProfile,
|
|
183
499
|
};
|
|
184
500
|
if (backupPath) result.backup_path = backupPath;
|
|
185
501
|
return result;
|
|
186
502
|
}
|
|
187
503
|
|
|
188
|
-
module.exports = {
|
|
504
|
+
module.exports = {
|
|
505
|
+
initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode, classifyRepository,
|
|
506
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
|
|
4
|
+
const PROVIDER_FIELDS = new Set([
|
|
5
|
+
'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
class ProviderRefsError extends Error {
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ProviderRefsError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeProviderRefs(value, ErrorClass = ProviderRefsError) {
|
|
17
|
+
if (value == null) return {};
|
|
18
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
19
|
+
throw new ErrorClass('VALIDATION_ERROR', 'provider_refs must be an object');
|
|
20
|
+
}
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [kind, ref] of Object.entries(value)) {
|
|
23
|
+
if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
24
|
+
throw new ErrorClass('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
|
|
25
|
+
}
|
|
26
|
+
for (const key of Object.keys(ref)) {
|
|
27
|
+
if (!PROVIDER_FIELDS.has(key)) {
|
|
28
|
+
throw new ErrorClass(
|
|
29
|
+
'PROVIDER_CONTENT_FORBIDDEN',
|
|
30
|
+
`provider_refs.${kind}.${key} is not metadata; provider content remains external`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
|
|
35
|
+
throw new ErrorClass('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
|
|
36
|
+
}
|
|
37
|
+
out[kind] = { ...ref, provider: ref.provider.trim() };
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { ProviderRefsError, normalizeProviderRefs };
|
|
@@ -20,6 +20,11 @@ function fixture() {
|
|
|
20
20
|
execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
|
|
21
21
|
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
|
|
22
22
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
23
|
+
db.prepare(
|
|
24
|
+
`INSERT INTO baselines
|
|
25
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
26
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
27
|
+
).run(new Date().toISOString());
|
|
23
28
|
changes.createChange(db, {
|
|
24
29
|
id: 'learning-change', title: 'Learning change', kind: 'quick',
|
|
25
30
|
intent: 'Exercise guarded specification-learning transitions.',
|
|
@@ -51,11 +51,17 @@ function assertStateAuthority(db, rootDir) {
|
|
|
51
51
|
projection_path: projection.path,
|
|
52
52
|
projection_version: projection.version,
|
|
53
53
|
};
|
|
54
|
-
|
|
54
|
+
const migrationTargets = {
|
|
55
|
+
'4.4': '4.5',
|
|
56
|
+
'4.5': '12.0',
|
|
57
|
+
};
|
|
58
|
+
const migrationTarget = migrationTargets[projection.version];
|
|
59
|
+
if (migrationTarget) {
|
|
55
60
|
throw new StateAuthorityError(
|
|
56
61
|
'LEGACY_STATE_MIGRATION_REQUIRED',
|
|
57
|
-
`state.db has no tasks but ${projection.path} contains ${projection.taskCount}
|
|
58
|
-
+
|
|
62
|
+
`state.db has no tasks but ${projection.path} contains ${projection.taskCount} v${projection.version} tasks; `
|
|
63
|
+
+ `run ultra-tools migrate --from=${projection.version} --to=${migrationTarget} `
|
|
64
|
+
+ '--source-dir <project-root> before using Ultra MCP tools',
|
|
59
65
|
details,
|
|
60
66
|
);
|
|
61
67
|
}
|