ultra-builder-pro-cli 0.14.0 → 0.15.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 +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +44 -1
- package/README.md +34 -19
- package/adapters/_shared/codex-assets.cjs +6 -2
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +3 -2
- package/agents/review-coordinator.md +9 -6
- package/docs/AGENT-CONTEXT.md +15 -8
- package/docs/ARCHITECTURE.md +33 -12
- package/docs/PLAN.zh-CN.md +3 -2
- package/docs/ROADMAP.md +8 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
- package/docs/WORKFLOW-LIFECYCLE.md +266 -0
- package/hooks/README.md +6 -1
- package/hooks/health_check.py +12 -3
- package/hooks/subagent_tracker.py +52 -77
- package/mcp-server/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +550 -85
- package/mcp-server/lib/context-spine.cjs +242 -24
- package/mcp-server/lib/doctor.cjs +33 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +6 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +145 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/workflow-state.cjs +2292 -0
- package/mcp-server/server.cjs +161 -15
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +13 -3
- package/skills/ultra-change/SKILL.md +53 -68
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +69 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +54 -59
- package/skills/ultra-doctor/SKILL.md +40 -71
- package/skills/ultra-init/SKILL.md +96 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +79 -85
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +87 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +57 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -45
- package/skills/ultra-test/SKILL.md +48 -55
- package/skills/ultra-verify/SKILL.md +1 -1
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/spec/cli-protocol.md +7 -2
- package/spec/mcp-tools.yaml +384 -63
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +82 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +213 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -138
- package/adapters/tests/codex-hook.test.cjs +0 -142
- package/adapters/tests/codex.test.cjs +0 -430
- package/adapters/tests/kimi-hook.test.cjs +0 -149
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -357
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -82
- package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -169
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1467
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
const fs = require('node:fs');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
|
-
const { initStateDb } = require('./state-db.cjs');
|
|
17
|
+
const { EXPECTED_VERSION, initStateDb } = require('./state-db.cjs');
|
|
18
18
|
const { assertStateAuthority, inspectProjection } = require('./state-authority.cjs');
|
|
19
19
|
const baselines = require('./baseline-workflow.cjs');
|
|
20
20
|
const runtimeState = require('./runtime-state.cjs');
|
|
21
21
|
const ops = require('./state-ops.cjs');
|
|
22
|
+
const workflows = require('./workflow-state.cjs');
|
|
22
23
|
|
|
23
24
|
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
24
25
|
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
@@ -137,13 +138,12 @@ const MANIFEST_NAMES = new Set([
|
|
|
137
138
|
'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json',
|
|
138
139
|
]);
|
|
139
140
|
const SOURCE_EXTENSIONS = new Set([
|
|
140
|
-
'.
|
|
141
|
-
'.
|
|
141
|
+
'.astro', '.c', '.cc', '.cpp', '.cs', '.css', '.dart', '.ex', '.exs', '.fs', '.fsx',
|
|
142
|
+
'.go', '.graphql', '.gql', '.hcl', '.html', '.java', '.js', '.jsx', '.kt', '.kts',
|
|
143
|
+
'.less', '.lua', '.mjs', '.move', '.php', '.proto', '.py', '.r', '.R', '.rb', '.rs',
|
|
144
|
+
'.sass', '.scala', '.scss', '.sh', '.sol', '.svelte', '.swift', '.tf', '.ts', '.tsx',
|
|
145
|
+
'.vue',
|
|
142
146
|
]);
|
|
143
|
-
const SOURCE_DIR_SEGMENTS = new Set([
|
|
144
|
-
'src', 'app', 'apps', 'lib', 'server', 'backend', 'frontend', 'api', 'cmd', 'internal',
|
|
145
|
-
]);
|
|
146
|
-
|
|
147
147
|
function scanRepositoryFiles(rootDir, { maxFiles = 5000, maxDepth = 8 } = {}) {
|
|
148
148
|
const files = [];
|
|
149
149
|
function walk(dir, depth) {
|
|
@@ -198,6 +198,122 @@ function packageProfile(rootDir) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
const JAVASCRIPT_TECHNOLOGIES = Object.freeze([
|
|
202
|
+
{ packages: ['next'], name: 'Next.js', capability: 'frontend' },
|
|
203
|
+
{ packages: ['react'], name: 'React', capability: 'frontend' },
|
|
204
|
+
{ packages: ['vue', 'nuxt'], name: 'Vue', capability: 'frontend' },
|
|
205
|
+
{ packages: ['svelte', '@sveltejs/kit'], name: 'Svelte', capability: 'frontend' },
|
|
206
|
+
{ packages: ['@angular/core'], name: 'Angular', capability: 'frontend' },
|
|
207
|
+
{ packages: ['express'], name: 'Express', capability: 'backend' },
|
|
208
|
+
{ packages: ['fastify'], name: 'Fastify', capability: 'backend' },
|
|
209
|
+
{ packages: ['@nestjs/core'], name: 'NestJS', capability: 'backend' },
|
|
210
|
+
{ packages: ['hono'], name: 'Hono', capability: 'backend' },
|
|
211
|
+
{ packages: ['koa'], name: 'Koa', capability: 'backend' },
|
|
212
|
+
{ packages: ['commander', 'yargs', 'oclif'], name: 'Node CLI', capability: 'cli' },
|
|
213
|
+
{ packages: ['@prisma/client', 'prisma'], name: 'Prisma' },
|
|
214
|
+
{ packages: ['typescript'], name: 'TypeScript' },
|
|
215
|
+
{ packages: ['vitest'], name: 'Vitest' },
|
|
216
|
+
{ packages: ['jest'], name: 'Jest' },
|
|
217
|
+
{ packages: ['playwright', '@playwright/test'], name: 'Playwright' },
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
function readTextBounded(file, maxBytes = 256 * 1024) {
|
|
221
|
+
try {
|
|
222
|
+
const stat = fs.statSync(file);
|
|
223
|
+
if (!stat.isFile() || stat.size > maxBytes) return '';
|
|
224
|
+
return fs.readFileSync(file, 'utf8');
|
|
225
|
+
} catch {
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function detectTechnologyProfile(rootDir, files) {
|
|
231
|
+
const technologies = new Set();
|
|
232
|
+
const capabilities = new Set();
|
|
233
|
+
const packageFiles = files.filter((file) => path.basename(file) === 'package.json').slice(0, 100);
|
|
234
|
+
for (const relative of packageFiles) {
|
|
235
|
+
let pkg;
|
|
236
|
+
try { pkg = JSON.parse(readTextBounded(path.join(rootDir, relative))); }
|
|
237
|
+
catch { continue; }
|
|
238
|
+
technologies.add('Node.js');
|
|
239
|
+
const dependencies = new Set([
|
|
240
|
+
...Object.keys(pkg.dependencies || {}),
|
|
241
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
242
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
243
|
+
]);
|
|
244
|
+
if (pkg.bin && (typeof pkg.bin === 'string' || typeof pkg.bin === 'object')) {
|
|
245
|
+
capabilities.add('cli');
|
|
246
|
+
}
|
|
247
|
+
for (const technology of JAVASCRIPT_TECHNOLOGIES) {
|
|
248
|
+
if (!technology.packages.some((name) => dependencies.has(name))) continue;
|
|
249
|
+
technologies.add(technology.name);
|
|
250
|
+
if (technology.capability) capabilities.add(technology.capability);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (files.some((file) => file === 'tsconfig.json' || /\.(ts|tsx)$/.test(file))) {
|
|
255
|
+
technologies.add('TypeScript');
|
|
256
|
+
}
|
|
257
|
+
if (files.some((file) => /(^|\/)index\.html$/.test(file))) {
|
|
258
|
+
technologies.add('HTML');
|
|
259
|
+
capabilities.add('frontend');
|
|
260
|
+
}
|
|
261
|
+
if (files.some((file) => /\.(css|scss|sass|less)$/.test(file))) technologies.add('CSS');
|
|
262
|
+
|
|
263
|
+
const pythonFiles = files.filter((file) => (
|
|
264
|
+
['pyproject.toml', 'requirements.txt'].includes(path.basename(file)) || file.endsWith('.py')
|
|
265
|
+
));
|
|
266
|
+
if (pythonFiles.length > 0) {
|
|
267
|
+
technologies.add('Python');
|
|
268
|
+
const declarations = pythonFiles
|
|
269
|
+
.filter((file) => ['pyproject.toml', 'requirements.txt'].includes(path.basename(file)))
|
|
270
|
+
.map((file) => readTextBounded(path.join(rootDir, file)).toLowerCase())
|
|
271
|
+
.join('\n');
|
|
272
|
+
for (const [needle, label, capability] of [
|
|
273
|
+
['fastapi', 'FastAPI', 'backend'], ['django', 'Django', 'backend'],
|
|
274
|
+
['flask', 'Flask', 'backend'], ['typer', 'Typer', 'cli'], ['click', 'Click', 'cli'],
|
|
275
|
+
['pytest', 'Pytest', null],
|
|
276
|
+
]) {
|
|
277
|
+
if (!declarations.includes(needle)) continue;
|
|
278
|
+
technologies.add(label);
|
|
279
|
+
if (capability) capabilities.add(capability);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (files.some((file) => ['go.mod', 'go.work'].includes(path.basename(file)) || file.endsWith('.go'))) {
|
|
283
|
+
technologies.add('Go');
|
|
284
|
+
}
|
|
285
|
+
if (files.some((file) => path.basename(file) === 'Cargo.toml' || file.endsWith('.rs'))) {
|
|
286
|
+
technologies.add('Rust');
|
|
287
|
+
const cargo = files.filter((file) => path.basename(file) === 'Cargo.toml')
|
|
288
|
+
.map((file) => readTextBounded(path.join(rootDir, file)).toLowerCase()).join('\n');
|
|
289
|
+
if (cargo.includes('clap')) {
|
|
290
|
+
technologies.add('Clap');
|
|
291
|
+
capabilities.add('cli');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (files.some((file) => ['pom.xml', 'build.gradle', 'build.gradle.kts'].includes(path.basename(file)))) {
|
|
295
|
+
technologies.add('JVM');
|
|
296
|
+
}
|
|
297
|
+
if (files.some((file) => path.basename(file) === 'Gemfile' || file.endsWith('.rb'))) {
|
|
298
|
+
technologies.add('Ruby');
|
|
299
|
+
}
|
|
300
|
+
if (files.some((file) => path.basename(file) === 'composer.json' || file.endsWith('.php'))) {
|
|
301
|
+
technologies.add('PHP');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let projectType = 'other';
|
|
305
|
+
if (capabilities.has('frontend') && capabilities.has('backend')) projectType = 'fullstack';
|
|
306
|
+
else if (capabilities.has('frontend')) projectType = 'web';
|
|
307
|
+
else if (capabilities.has('backend')) projectType = 'api';
|
|
308
|
+
else if (capabilities.has('cli')) projectType = 'cli';
|
|
309
|
+
return {
|
|
310
|
+
detected_project_type: projectType,
|
|
311
|
+
detected_stack: technologies.size > 0 ? [...technologies].join(', ') : null,
|
|
312
|
+
technology_signals: [...technologies],
|
|
313
|
+
capability_signals: [...capabilities].sort(),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
201
317
|
function classifyRepository(absTarget) {
|
|
202
318
|
const { files, truncated } = scanRepositoryFiles(absTarget);
|
|
203
319
|
const packageData = packageProfile(absTarget);
|
|
@@ -220,12 +336,10 @@ function classifyRepository(absTarget) {
|
|
|
220
336
|
}).slice(0, 100);
|
|
221
337
|
const sourceSignals = files.filter((file) => {
|
|
222
338
|
const lower = file.toLowerCase();
|
|
223
|
-
if (testSignals.includes(file) ||
|
|
224
|
-
const segments = file.split('/');
|
|
339
|
+
if (testSignals.includes(file) || /(^|\/)[^/]+\.config\.(c|m)?(j|t)s$/.test(lower)) return false;
|
|
225
340
|
const ext = path.extname(file);
|
|
226
341
|
if (!SOURCE_EXTENSIONS.has(ext)) return false;
|
|
227
|
-
return
|
|
228
|
-
|| /(^|\/)(main|index|server|app)\.[^.]+$/.test(lower);
|
|
342
|
+
return true;
|
|
229
343
|
}).slice(0, 200);
|
|
230
344
|
const workspaceRoots = [...new Set(packageData.workspacePatterns
|
|
231
345
|
.flatMap((pattern) => expandWorkspacePattern(absTarget, pattern)))]
|
|
@@ -247,12 +361,15 @@ function classifyRepository(absTarget) {
|
|
|
247
361
|
if (deploymentSignals.length > 0) reasons.push('DEPLOYMENT_PRESENT');
|
|
248
362
|
if (databaseSignals.length > 0) reasons.push('PERSISTED_STATE_PRESENT');
|
|
249
363
|
if (workspaceMarkers.length > 0 || workspaceRoots.length > 0) reasons.push('MONOREPO_PRESENT');
|
|
364
|
+
if (truncated) reasons.push('SCAN_TRUNCATED');
|
|
250
365
|
if (reasons.length === 0) reasons.push('SKELETON_ONLY');
|
|
251
366
|
const mode = sourceSignals.length > 0 || testSignals.length > 0
|
|
252
|
-
|| deploymentSignals.length > 0 || databaseSignals.length > 0
|
|
367
|
+
|| deploymentSignals.length > 0 || databaseSignals.length > 0 || truncated
|
|
253
368
|
? 'brownfield' : 'greenfield';
|
|
369
|
+
const technologyProfile = detectTechnologyProfile(absTarget, files);
|
|
254
370
|
return {
|
|
255
371
|
mode,
|
|
372
|
+
...technologyProfile,
|
|
256
373
|
reasons,
|
|
257
374
|
repository_kind: workspaceMarkers.length > 0 || workspaceRoots.length > 0 ? 'monorepo' : 'single',
|
|
258
375
|
workspace_markers: workspaceMarkers,
|
|
@@ -290,7 +407,7 @@ function rollbackInitialization(ultraDir, backupPath) {
|
|
|
290
407
|
}
|
|
291
408
|
|
|
292
409
|
function projectionMigrationError(projection) {
|
|
293
|
-
const targets = { '4.4': '4.5', '4.5':
|
|
410
|
+
const targets = { '4.4': '4.5', '4.5': EXPECTED_VERSION };
|
|
294
411
|
const target = targets[projection.version];
|
|
295
412
|
if (target) {
|
|
296
413
|
return new InitProjectError(
|
|
@@ -307,6 +424,67 @@ function projectionMigrationError(projection) {
|
|
|
307
424
|
);
|
|
308
425
|
}
|
|
309
426
|
|
|
427
|
+
function ensureInitializationWorkflows(db, baseline, { rootDir }) {
|
|
428
|
+
let initRun = workflows.listWorkflows(
|
|
429
|
+
db, { kind: 'init', baseline_id: baseline.id, limit: 1 }, { rootDir },
|
|
430
|
+
)[0];
|
|
431
|
+
let researchRun = workflows.listWorkflows(
|
|
432
|
+
db, { kind: 'research', baseline_id: baseline.id, limit: 1 }, { rootDir },
|
|
433
|
+
)[0];
|
|
434
|
+
if (baseline.status === 'ready') {
|
|
435
|
+
const compatibilityOnly = baseline.mode === 'migrated';
|
|
436
|
+
const provenanceComplete = !compatibilityOnly
|
|
437
|
+
&& initRun?.status === 'completed'
|
|
438
|
+
&& researchRun?.status === 'completed'
|
|
439
|
+
&& baseline.research_run_id === researchRun.id;
|
|
440
|
+
return {
|
|
441
|
+
init_id: initRun?.id || null,
|
|
442
|
+
init_status: initRun?.status || 'missing',
|
|
443
|
+
research_id: researchRun?.id || null,
|
|
444
|
+
research_mode: researchRun?.mode || null,
|
|
445
|
+
research_status: compatibilityOnly
|
|
446
|
+
? 'not_required' : (researchRun?.status || 'missing'),
|
|
447
|
+
provenance_status: compatibilityOnly
|
|
448
|
+
? 'compatibility_only' : (provenanceComplete ? 'complete' : 'incomplete'),
|
|
449
|
+
next_action: compatibilityOnly
|
|
450
|
+
? 'ultra-init' : (provenanceComplete ? 'ultra-change' : 'ultra-doctor'),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
if (!initRun) {
|
|
454
|
+
initRun = workflows.startWorkflow(db, {
|
|
455
|
+
kind: 'init', baseline_id: baseline.id,
|
|
456
|
+
subject: `Establish the ${baseline.mode} baseline for ${baseline.project_name}.`,
|
|
457
|
+
metadata: { repository_revision: baseline.repository_revision, scope: baseline.scope },
|
|
458
|
+
}, { rootDir });
|
|
459
|
+
for (const stepId of ['inspect-authority', 'classify-repository', 'scaffold-authority']) {
|
|
460
|
+
initRun = workflows.recordWorkflowStep(db, {
|
|
461
|
+
id: initRun.id, step_id: stepId, status: 'completed',
|
|
462
|
+
}, { rootDir });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (baseline.mode !== 'migrated') {
|
|
467
|
+
if (!researchRun) {
|
|
468
|
+
const researchMode = baseline.mode === 'brownfield' ? 'adoption' : 'full';
|
|
469
|
+
researchRun = workflows.startWorkflow(db, {
|
|
470
|
+
kind: 'research', mode: researchMode, baseline_id: baseline.id,
|
|
471
|
+
subject: baseline.mode === 'brownfield'
|
|
472
|
+
? 'Adopt the existing system as an evidence-backed product and architecture baseline.'
|
|
473
|
+
: 'Validate the complete product and architecture baseline before planning.',
|
|
474
|
+
}, { rootDir });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
init_id: initRun.id,
|
|
479
|
+
init_status: initRun.status,
|
|
480
|
+
research_id: researchRun?.id || null,
|
|
481
|
+
research_mode: researchRun?.mode || null,
|
|
482
|
+
research_status: researchRun?.status || 'not_started',
|
|
483
|
+
provenance_status: 'in_progress',
|
|
484
|
+
next_action: baseline.mode === 'migrated' ? 'ultra-init' : 'ultra-research',
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
310
488
|
function resumeProject({
|
|
311
489
|
absTarget, ultraDir, source, project_name, project_type, stack, resolvedMode,
|
|
312
490
|
scope, repositoryProfile, projection,
|
|
@@ -316,6 +494,7 @@ function resumeProject({
|
|
|
316
494
|
let initialized;
|
|
317
495
|
let copiedFiles = [];
|
|
318
496
|
let baselineResult;
|
|
497
|
+
let workflowResult;
|
|
319
498
|
try {
|
|
320
499
|
initialized = initStateDb(stateDbPath);
|
|
321
500
|
const { db } = initialized;
|
|
@@ -331,6 +510,7 @@ function resumeProject({
|
|
|
331
510
|
classification: repositoryProfile,
|
|
332
511
|
}, { rootDir: absTarget });
|
|
333
512
|
}
|
|
513
|
+
workflowResult = ensureInitializationWorkflows(db, baselineResult, { rootDir: absTarget });
|
|
334
514
|
ops.appendEvent(db, {
|
|
335
515
|
type: 'project_resumed',
|
|
336
516
|
payload: {
|
|
@@ -367,16 +547,19 @@ function resumeProject({
|
|
|
367
547
|
created_path: ultraDir,
|
|
368
548
|
state_db_path: stateDbPath,
|
|
369
549
|
status: 'resumed',
|
|
370
|
-
mode:
|
|
550
|
+
mode: baselineResult.mode,
|
|
371
551
|
baseline: {
|
|
372
552
|
id: baselineResult.id,
|
|
373
553
|
mode: baselineResult.mode,
|
|
374
554
|
status: baselineResult.status,
|
|
555
|
+
project_type: baselineResult.project_type,
|
|
556
|
+
stack: baselineResult.stack,
|
|
375
557
|
repository_revision: baselineResult.repository_revision,
|
|
376
558
|
scope: baselineResult.scope,
|
|
377
559
|
},
|
|
378
560
|
copied_files: copiedFiles,
|
|
379
561
|
repository_profile: repositoryProfile,
|
|
562
|
+
workflow: workflowResult,
|
|
380
563
|
};
|
|
381
564
|
if (initialized.backup_path) result.migration_backup_path = initialized.backup_path;
|
|
382
565
|
return result;
|
|
@@ -404,8 +587,12 @@ function initProject({
|
|
|
404
587
|
ensureTargetDir(absTarget);
|
|
405
588
|
const repositoryProfile = classifyRepository(absTarget);
|
|
406
589
|
const resolvedMode = mode === 'auto' ? repositoryProfile.mode : detectProjectMode(absTarget, mode);
|
|
590
|
+
const resolvedProjectType = project_type || repositoryProfile.detected_project_type;
|
|
591
|
+
const resolvedStack = stack || repositoryProfile.detected_stack || undefined;
|
|
407
592
|
repositoryProfile.detected_mode = repositoryProfile.mode;
|
|
408
593
|
repositoryProfile.mode = resolvedMode;
|
|
594
|
+
repositoryProfile.selected_project_type = resolvedProjectType;
|
|
595
|
+
repositoryProfile.selected_stack = resolvedStack || null;
|
|
409
596
|
const source = ensureTemplate(source_template);
|
|
410
597
|
const ultraDir = path.join(absTarget, '.ultra');
|
|
411
598
|
|
|
@@ -422,7 +609,8 @@ function initProject({
|
|
|
422
609
|
throw projectionMigrationError(projection);
|
|
423
610
|
}
|
|
424
611
|
return resumeProject({
|
|
425
|
-
absTarget, ultraDir, source, project_name, project_type
|
|
612
|
+
absTarget, ultraDir, source, project_name, project_type: resolvedProjectType,
|
|
613
|
+
stack: resolvedStack, resolvedMode,
|
|
426
614
|
scope, repositoryProfile, projection,
|
|
427
615
|
});
|
|
428
616
|
}
|
|
@@ -441,6 +629,7 @@ function initProject({
|
|
|
441
629
|
|
|
442
630
|
let copiedFiles;
|
|
443
631
|
let baselineResult;
|
|
632
|
+
let workflowResult;
|
|
444
633
|
try {
|
|
445
634
|
fs.mkdirSync(ultraDir, { recursive: true });
|
|
446
635
|
copiedFiles = copyTemplate(source, ultraDir);
|
|
@@ -449,11 +638,13 @@ function initProject({
|
|
|
449
638
|
const { db } = initStateDb(stateDbPath);
|
|
450
639
|
try {
|
|
451
640
|
baselineResult = baselines.startBaseline(db, {
|
|
452
|
-
id: 'project-baseline', project_name, project_type
|
|
641
|
+
id: 'project-baseline', project_name, project_type: resolvedProjectType,
|
|
642
|
+
stack: resolvedStack,
|
|
453
643
|
mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
|
|
454
644
|
scope: scope === undefined ? ['.'] : scope,
|
|
455
645
|
classification: repositoryProfile,
|
|
456
646
|
}, { rootDir: absTarget });
|
|
647
|
+
workflowResult = ensureInitializationWorkflows(db, baselineResult, { rootDir: absTarget });
|
|
457
648
|
ops.appendEvent(db, {
|
|
458
649
|
type: 'project_initialized',
|
|
459
650
|
payload: {
|
|
@@ -491,11 +682,14 @@ function initProject({
|
|
|
491
682
|
id: baselineResult.id,
|
|
492
683
|
mode: baselineResult.mode,
|
|
493
684
|
status: baselineResult.status,
|
|
685
|
+
project_type: baselineResult.project_type,
|
|
686
|
+
stack: baselineResult.stack,
|
|
494
687
|
repository_revision: baselineResult.repository_revision,
|
|
495
688
|
scope: baselineResult.scope,
|
|
496
689
|
},
|
|
497
690
|
copied_files: copiedFiles,
|
|
498
691
|
repository_profile: repositoryProfile,
|
|
692
|
+
workflow: workflowResult,
|
|
499
693
|
};
|
|
500
694
|
if (backupPath) result.backup_path = backupPath;
|
|
501
695
|
return result;
|
|
@@ -18,8 +18,11 @@ function renderPlanMd(plan) {
|
|
|
18
18
|
const lines = [];
|
|
19
19
|
lines.push('# Execution Plan');
|
|
20
20
|
lines.push('');
|
|
21
|
+
if (plan.change_id) lines.push(`- Change: ${plan.change_id}`);
|
|
21
22
|
lines.push(`- Waves: ${plan.waves.length}`);
|
|
22
|
-
lines.push(
|
|
23
|
+
lines.push(plan.estimated_cost_usd === null
|
|
24
|
+
? '- Estimated cost: unavailable (exact runtime model was not recorded)'
|
|
25
|
+
: `- Estimated cost: $${plan.estimated_cost_usd}`);
|
|
23
26
|
lines.push(`- Estimated duration: ${plan.estimated_duration_min} min`);
|
|
24
27
|
if (plan.conflict_surface.length > 0) {
|
|
25
28
|
lines.push(`- Conflicts: ${plan.conflict_surface.length}`);
|
|
@@ -10,31 +10,28 @@
|
|
|
10
10
|
// Claude: https://www.anthropic.com/pricing
|
|
11
11
|
// OpenAI: https://platform.openai.com/docs/pricing
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// Pricing is exact-model only. A runtime label is not a billable model and
|
|
14
|
+
// host applications can route the same runtime through different providers.
|
|
15
|
+
// Missing or unknown models therefore return null instead of a fabricated
|
|
16
|
+
// estimate.
|
|
15
17
|
|
|
16
18
|
const PRICING = Object.freeze({
|
|
17
19
|
claude: Object.freeze({
|
|
18
20
|
'claude-opus-4-7': { input: 15e-6, output: 75e-6 },
|
|
19
21
|
'claude-sonnet-4-6': { input: 3e-6, output: 15e-6 },
|
|
20
22
|
'claude-haiku-4-5': { input: 0.8e-6, output: 4e-6 },
|
|
21
|
-
default: { input: 3e-6, output: 15e-6 }, // assume sonnet
|
|
22
23
|
}),
|
|
23
24
|
codex: Object.freeze({
|
|
24
25
|
'gpt-5.4': { input: 2e-6, output: 10e-6 },
|
|
25
|
-
default: { input: 2e-6, output: 10e-6 },
|
|
26
|
-
}),
|
|
27
|
-
opencode: Object.freeze({
|
|
28
|
-
// OpenCode is a frontend over whichever model the user configured;
|
|
29
|
-
// treat as sonnet-equivalent unless a model name is passed in.
|
|
30
|
-
default: { input: 3e-6, output: 15e-6 },
|
|
31
26
|
}),
|
|
27
|
+
opencode: Object.freeze({}),
|
|
28
|
+
kimi: Object.freeze({}),
|
|
32
29
|
});
|
|
33
30
|
|
|
34
31
|
function computeCost(runtime, model, tokens_input, tokens_output) {
|
|
35
32
|
const r = PRICING[runtime];
|
|
36
33
|
if (!r) return null;
|
|
37
|
-
const p =
|
|
34
|
+
const p = model ? r[model] : null;
|
|
38
35
|
if (!p) return null;
|
|
39
36
|
const ti = Number(tokens_input) || 0;
|
|
40
37
|
const to = Number(tokens_output) || 0;
|
|
@@ -120,6 +120,12 @@ function renderProjectBreadcrumb(rootDir, breadcrumb) {
|
|
|
120
120
|
if (Array.isArray(breadcrumb.warnings) && breadcrumb.warnings.length > 0) {
|
|
121
121
|
lines.push(`Warnings: ${breadcrumb.warnings.join(', ')}`);
|
|
122
122
|
}
|
|
123
|
+
if (breadcrumb.workflow) {
|
|
124
|
+
lines.push(
|
|
125
|
+
`Workflow: ${breadcrumb.workflow.id} (${breadcrumb.workflow.kind}/${breadcrumb.workflow.status})`,
|
|
126
|
+
`Step: ${breadcrumb.workflow.current_step || 'finalize'}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
123
129
|
lines.push(
|
|
124
130
|
`Next: ${breadcrumb.next_action || 'Inspect Ultra status.'}`,
|
|
125
131
|
`Route: ${breadcrumb.recommended_workflow || 'ultra-doctor'}`,
|
|
@@ -16,8 +16,11 @@ const SCHEMA_VERSION = '4.5';
|
|
|
16
16
|
const SOURCE_TAG = '.ultra/state.db';
|
|
17
17
|
|
|
18
18
|
// Frozen SELECTs — values bind through @placeholders.
|
|
19
|
-
const
|
|
20
|
-
const
|
|
19
|
+
const TASK_PROJECTION_COLUMNS = 'id, title, type, priority, complexity, estimated_days, status, deps, files_modified, session_id, stale, tag, trace_to, outcome, slice_kind, public_seam, verification_command, acceptance_json, context_refs_json, docs_impact_json, ownership_json, context_file, completion_commit, created_at, updated_at';
|
|
20
|
+
const LIST_TASKS_FOR_PROJECTION_SQL = `SELECT ${TASK_PROJECTION_COLUMNS} FROM tasks ORDER BY created_at ASC`;
|
|
21
|
+
const READ_TASK_FOR_PROJECTION_SQL = `SELECT ${TASK_PROJECTION_COLUMNS} FROM tasks WHERE id = @id`;
|
|
22
|
+
const CONTRACT_START = '<!-- ultra:task-contract:start -->';
|
|
23
|
+
const CONTRACT_END = '<!-- ultra:task-contract:end -->';
|
|
21
24
|
|
|
22
25
|
function rowToProjection(row) {
|
|
23
26
|
if (!row) return null;
|
|
@@ -28,6 +31,17 @@ function rowToProjection(row) {
|
|
|
28
31
|
}
|
|
29
32
|
if (out[k] === null || out[k] === undefined) delete out[k];
|
|
30
33
|
}
|
|
34
|
+
const jsonFields = {
|
|
35
|
+
acceptance_json: ['acceptance', []],
|
|
36
|
+
context_refs_json: ['context_refs', []],
|
|
37
|
+
docs_impact_json: ['docs_impact', { status: 'unknown', files: [], rationale: null }],
|
|
38
|
+
ownership_json: ['ownership', {}],
|
|
39
|
+
};
|
|
40
|
+
for (const [column, [field, fallback]] of Object.entries(jsonFields)) {
|
|
41
|
+
try { out[field] = typeof out[column] === 'string' ? JSON.parse(out[column]) : fallback; }
|
|
42
|
+
catch { out[field] = fallback; }
|
|
43
|
+
delete out[column];
|
|
44
|
+
}
|
|
31
45
|
if (out.stale !== undefined && out.stale !== null) out.stale = Boolean(out.stale);
|
|
32
46
|
for (const k of Object.keys(out)) {
|
|
33
47
|
if (out[k] === null || out[k] === undefined) delete out[k];
|
|
@@ -94,17 +108,57 @@ function buildContextDoc(taskRow, existingBody) {
|
|
|
94
108
|
body = body.replace(/^>\s*\*\*Status\*\*:.*(?:\r?\n|$)(?:\r?\n)?/mi, '');
|
|
95
109
|
// Strip any previous STALE banner so re-projection after reset is clean.
|
|
96
110
|
body = body.replace(/^> ⚠️\s*STALE[^\n]*\n(?:>[^\n]*\n)*\n?/m, '');
|
|
111
|
+
body = body.replace(
|
|
112
|
+
new RegExp(`${CONTRACT_START}[\\s\\S]*?${CONTRACT_END}\\n*`, 'm'),
|
|
113
|
+
'',
|
|
114
|
+
);
|
|
97
115
|
if (taskRow.stale) {
|
|
98
116
|
const banner = [
|
|
99
117
|
`> ⚠️ STALE since ${taskRow.updated_at || new Date().toISOString()}`,
|
|
100
|
-
'>
|
|
118
|
+
'> Specification evidence may have changed. Recompile the task context before continuing.',
|
|
101
119
|
'',
|
|
102
120
|
].join('\n');
|
|
103
121
|
body = banner + body;
|
|
104
122
|
}
|
|
123
|
+
body = buildTaskContract(taskRow) + body;
|
|
105
124
|
return headerLines.join('\n') + body;
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
function buildTaskContract(task) {
|
|
128
|
+
const acceptance = Array.isArray(task.acceptance) ? task.acceptance : [];
|
|
129
|
+
const refs = Array.isArray(task.context_refs) ? task.context_refs : [];
|
|
130
|
+
const docs = task.docs_impact || { status: 'unknown', files: [], rationale: null };
|
|
131
|
+
const ownership = task.ownership || {};
|
|
132
|
+
const lines = [
|
|
133
|
+
CONTRACT_START,
|
|
134
|
+
'## Execution Contract (generated from state.db)',
|
|
135
|
+
'',
|
|
136
|
+
`- Outcome: ${task.outcome || 'unresolved'}`,
|
|
137
|
+
`- Slice: ${task.slice_kind || 'unresolved'}`,
|
|
138
|
+
`- Public seam: ${task.public_seam || 'unresolved'}`,
|
|
139
|
+
`- Verification: ${task.verification_command || 'unresolved'}`,
|
|
140
|
+
`- Trace: ${task.trace_to || 'unresolved'}`,
|
|
141
|
+
`- Owner: ${ownership.owner || 'unresolved'}`,
|
|
142
|
+
`- Documentation: ${docs.status || 'unknown'}${docs.rationale ? ` — ${docs.rationale}` : ''}`,
|
|
143
|
+
'',
|
|
144
|
+
'### Acceptance',
|
|
145
|
+
'',
|
|
146
|
+
...(acceptance.length > 0
|
|
147
|
+
? acceptance.map((item) => `- ${item.id}: ${item.criterion} — verify with \`${item.verification}\``)
|
|
148
|
+
: ['- unresolved']),
|
|
149
|
+
'',
|
|
150
|
+
'### Context References',
|
|
151
|
+
'',
|
|
152
|
+
...(refs.length > 0
|
|
153
|
+
? refs.map((item) => `- \`${item.ref}\` — ${item.reason}`)
|
|
154
|
+
: ['- unresolved']),
|
|
155
|
+
'',
|
|
156
|
+
CONTRACT_END,
|
|
157
|
+
'',
|
|
158
|
+
];
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
108
162
|
function escapeYaml(str) {
|
|
109
163
|
if (str == null) return '';
|
|
110
164
|
if (/[:#\[\]\{\}&\*!|>'"%@`]/.test(str) || /^\s|\s$/.test(str)) {
|
|
@@ -33,8 +33,14 @@ function rowToCandidate(row) {
|
|
|
33
33
|
catch (error) {
|
|
34
34
|
throw new SpecLearningError('STATE_CORRUPT', `invalid spec-learning evidence: ${error.message}`);
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
let applyEvidence;
|
|
37
|
+
try { applyEvidence = JSON.parse(row.apply_evidence_json || '[]'); }
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw new SpecLearningError('STATE_CORRUPT', `invalid spec-learning apply evidence: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
const candidate = { ...row, evidence, apply_evidence: applyEvidence };
|
|
37
42
|
delete candidate.evidence_json;
|
|
43
|
+
delete candidate.apply_evidence_json;
|
|
38
44
|
return candidate;
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -102,6 +108,66 @@ function normalizeEvidence(value) {
|
|
|
102
108
|
return [...new Set(value.map((entry) => entry.trim()))];
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
function targetFile(rootDir, targetRef) {
|
|
112
|
+
const separator = String(targetRef || '').lastIndexOf('#');
|
|
113
|
+
if (separator <= 0 || separator === targetRef.length - 1) {
|
|
114
|
+
throw new SpecLearningError('VALIDATION_ERROR', 'target_ref must use project-relative path#anchor');
|
|
115
|
+
}
|
|
116
|
+
const relative = targetRef.slice(0, separator);
|
|
117
|
+
if (path.isAbsolute(relative)) {
|
|
118
|
+
throw new SpecLearningError('VALIDATION_ERROR', 'target_ref must be project-relative');
|
|
119
|
+
}
|
|
120
|
+
const root = path.resolve(rootDir);
|
|
121
|
+
const file = path.resolve(root, relative);
|
|
122
|
+
if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
|
|
123
|
+
throw new SpecLearningError('VALIDATION_ERROR', 'target_ref escapes the project root');
|
|
124
|
+
}
|
|
125
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
126
|
+
throw new SpecLearningError('LEARNING_TARGET_MISSING', `target file does not exist: ${relative}`);
|
|
127
|
+
}
|
|
128
|
+
return { file, relative };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function digestFile(file) {
|
|
132
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function verifyApplyEvidence(input, current, rootDir) {
|
|
136
|
+
const appliedRef = typeof input.applied_ref === 'string' ? input.applied_ref.trim() : '';
|
|
137
|
+
const beforeDigest = typeof input.before_digest === 'string' ? input.before_digest.trim() : '';
|
|
138
|
+
const afterDigest = typeof input.after_digest === 'string' ? input.after_digest.trim() : '';
|
|
139
|
+
const evidence = normalizeEvidence(input.apply_evidence);
|
|
140
|
+
if (appliedRef !== current.target_ref || !/^[0-9a-f]{64}$/.test(beforeDigest)
|
|
141
|
+
|| !/^[0-9a-f]{64}$/.test(afterDigest) || beforeDigest === afterDigest
|
|
142
|
+
|| evidence.length === 0) {
|
|
143
|
+
throw new SpecLearningError(
|
|
144
|
+
'LEARNING_APPLY_EVIDENCE_REQUIRED',
|
|
145
|
+
'apply requires the exact target ref, distinct before/after digests, and durable evidence refs',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (current.before_digest && current.before_digest !== beforeDigest) {
|
|
149
|
+
throw new SpecLearningError(
|
|
150
|
+
'LEARNING_APPLY_EVIDENCE_MISMATCH', 'before_digest does not match the proposed target state',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const target = targetFile(rootDir, appliedRef);
|
|
154
|
+
if (digestFile(target.file) !== afterDigest) {
|
|
155
|
+
throw new SpecLearningError(
|
|
156
|
+
'LEARNING_APPLY_EVIDENCE_MISMATCH', 'after_digest does not match the current target file',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const workflows = require('./workflow-state.cjs');
|
|
160
|
+
try {
|
|
161
|
+
workflows.resolveProjectSourceRef(rootDir, appliedRef, 'applied_ref');
|
|
162
|
+
for (const ref of evidence) workflows.resolveProjectSourceRef(rootDir, ref, 'apply_evidence');
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new SpecLearningError(
|
|
165
|
+
'LEARNING_APPLY_EVIDENCE_MISMATCH', `applied target or evidence anchor is invalid: ${error.message}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { appliedRef, beforeDigest, afterDigest, evidence };
|
|
169
|
+
}
|
|
170
|
+
|
|
105
171
|
function proposeSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
|
|
106
172
|
const change = mutableChange(db, input.change_id);
|
|
107
173
|
const id = String(input.id || `learning-${crypto.randomUUID().slice(0, 12)}`).trim();
|
|
@@ -120,6 +186,8 @@ function proposeSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
120
186
|
}
|
|
121
187
|
}
|
|
122
188
|
const evidence = normalizeEvidence(input.evidence);
|
|
189
|
+
const proposedTarget = targetFile(rootDir, targetRef);
|
|
190
|
+
const proposedDigest = digestFile(proposedTarget.file);
|
|
123
191
|
const proposedAt = nowIso();
|
|
124
192
|
return ops.tx(db, () => {
|
|
125
193
|
try {
|
|
@@ -128,6 +196,9 @@ function proposeSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
128
196
|
(id, change_id, task_id, target_ref, summary, evidence_json, proposed_at)
|
|
129
197
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
130
198
|
).run(id, change.id, input.task_id || null, targetRef, summary, JSON.stringify(evidence), proposedAt);
|
|
199
|
+
db.prepare(
|
|
200
|
+
'UPDATE spec_learning_candidates SET before_digest = ? WHERE id = ? AND change_id = ?',
|
|
201
|
+
).run(proposedDigest, id, change.id);
|
|
131
202
|
} catch (error) {
|
|
132
203
|
if (String(error.message).includes('UNIQUE')) {
|
|
133
204
|
throw new SpecLearningError('DUPLICATE_LEARNING_ID', `spec-learning candidate ${id} exists`);
|
|
@@ -164,13 +235,19 @@ function resolveSpecLearning(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
164
235
|
throw new SpecLearningError('VALIDATION_ERROR', `${input.decision} requires a resolution`);
|
|
165
236
|
}
|
|
166
237
|
const timestamp = nowIso();
|
|
238
|
+
const application = status === 'applied'
|
|
239
|
+
? verifyApplyEvidence(input, current, rootDir)
|
|
240
|
+
: null;
|
|
167
241
|
return ops.tx(db, () => {
|
|
168
242
|
db.prepare(
|
|
169
243
|
`UPDATE spec_learning_candidates
|
|
170
|
-
SET status = ?, resolution = ?, resolved_at = ?, applied_at =
|
|
244
|
+
SET status = ?, resolution = ?, resolved_at = ?, applied_at = ?, applied_ref = ?,
|
|
245
|
+
before_digest = COALESCE(?, before_digest), after_digest = ?, apply_evidence_json = ?
|
|
171
246
|
WHERE id = ? AND change_id = ?`,
|
|
172
247
|
).run(
|
|
173
248
|
status, resolution || null, timestamp, status === 'applied' ? timestamp : null,
|
|
249
|
+
application?.appliedRef || null, application?.beforeDigest || null,
|
|
250
|
+
application?.afterDigest || null, JSON.stringify(application?.evidence || []),
|
|
174
251
|
current.id, change.id,
|
|
175
252
|
);
|
|
176
253
|
ops.appendEventInTx(db, {
|