ultra-builder-pro-cli 0.12.1 → 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.
Files changed (57) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +53 -1
  4. package/README.md +25 -9
  5. package/adapters/_shared/handbook.cjs +2 -2
  6. package/adapters/tests/codex.test.cjs +8 -0
  7. package/commands/ultra-init.md +1 -1
  8. package/docs/AGENT-CONTEXT.md +4 -3
  9. package/docs/ARCHITECTURE.md +32 -16
  10. package/docs/PLAN.zh-CN.md +5 -4
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  13. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  14. package/hooks/context_spine.py +37 -4
  15. package/mcp-server/lib/archive-journal.cjs +192 -0
  16. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  17. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  18. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  19. package/mcp-server/lib/change-workflow.cjs +249 -75
  20. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  21. package/mcp-server/lib/context-spine.test.cjs +5 -0
  22. package/mcp-server/lib/doctor.cjs +14 -1
  23. package/mcp-server/lib/doctor.test.cjs +42 -4
  24. package/mcp-server/lib/init-project.cjs +292 -9
  25. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  26. package/mcp-server/lib/state-authority.cjs +9 -3
  27. package/mcp-server/lib/state-db.cjs +154 -32
  28. package/mcp-server/lib/state-db.test.cjs +136 -4
  29. package/mcp-server/lib/state-ops.cjs +68 -6
  30. package/mcp-server/lib/task-expander.cjs +7 -1
  31. package/mcp-server/lib/task-expander.test.cjs +71 -0
  32. package/mcp-server/server.cjs +14 -3
  33. package/mcp-server/tests/init-project.test.cjs +162 -1
  34. package/mcp-server/tests/migrate.test.cjs +33 -0
  35. package/mcp-server/tests/server-contract.test.cjs +384 -6
  36. package/mcp-server/tests/state-db.test.cjs +14 -2
  37. package/mcp-server/tests/state-ops.test.cjs +108 -0
  38. package/package.json +1 -1
  39. package/skills/ultra-change/SKILL.md +18 -10
  40. package/skills/ultra-deliver/SKILL.md +13 -9
  41. package/skills/ultra-dev/SKILL.md +6 -4
  42. package/skills/ultra-doctor/SKILL.md +22 -7
  43. package/skills/ultra-init/SKILL.md +104 -59
  44. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  45. package/skills/ultra-plan/SKILL.md +8 -5
  46. package/skills/ultra-research/SKILL.md +12 -5
  47. package/skills/ultra-status/SKILL.md +6 -4
  48. package/spec/cli-protocol.md +11 -1
  49. package/spec/mcp-tools.yaml +95 -9
  50. package/spec/schemas/state-db.sql +11 -0
  51. package/ultra-tools/cli.cjs +9 -5
  52. package/ultra-tools/commands/migrate.cjs +62 -36
  53. package/ultra-tools/commands/status.cjs +18 -9
  54. package/ultra-tools/commands/system.cjs +340 -6
  55. package/ultra-tools/commands/task.cjs +7 -1
  56. package/ultra-tools/status-command.test.cjs +29 -0
  57. package/ultra-tools/system-command.test.cjs +252 -1
@@ -13,6 +13,11 @@ const MODES = new Set(['greenfield', 'brownfield']);
13
13
  const SPEC_KINDS = new Set(['discovery', 'product', 'architecture', 'quality', 'delivery', 'other']);
14
14
  const EVIDENCE_KINDS = new Set(['source', 'docs', 'runtime', 'test', 'deploy', 'external']);
15
15
  const VERIFICATION_STATUSES = new Set(['pass', 'known_red', 'not_run']);
16
+ const GAP_CATEGORIES = new Set([
17
+ 'baseline_blocker', 'documentation_drift', 'known_defect',
18
+ 'technical_debt', 'unknown', 'future_change',
19
+ ]);
20
+ const GAP_STATUSES = new Set(['open', 'accepted', 'resolved', 'deferred']);
16
21
 
17
22
  class BaselineWorkflowError extends Error {
18
23
  constructor(code, message, details) {
@@ -34,6 +39,70 @@ function gitHead(rootDir) {
34
39
  return result.status === 0 ? result.stdout.trim() : null;
35
40
  }
36
41
 
42
+ function gitBranch(rootDir) {
43
+ const result = spawnSync('git', ['branch', '--show-current'], {
44
+ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
45
+ });
46
+ return result.status === 0 ? (result.stdout.trim() || null) : null;
47
+ }
48
+
49
+ function gitWorktreeSnapshot(rootDir, scope = ['.']) {
50
+ const head = gitHead(rootDir);
51
+ if (!head) {
52
+ return {
53
+ head: null, branch: null, state: 'unavailable', digest: null, files: [],
54
+ };
55
+ }
56
+ const selectedScope = Array.isArray(scope) && scope.length > 0 ? scope : ['.'];
57
+ const pathspecs = selectedScope.map((item) => (
58
+ item === '.' ? '.' : `:(literal)${item}`
59
+ ));
60
+ const args = ['--', ...pathspecs, ':(exclude).ultra', ':(exclude).ultra/**'];
61
+ const status = spawnSync(
62
+ 'git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...args],
63
+ { cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
64
+ );
65
+ const diff = spawnSync(
66
+ 'git', ['diff', '--binary', '--no-ext-diff', 'HEAD', ...args],
67
+ { cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
68
+ );
69
+ const untracked = spawnSync(
70
+ 'git', ['ls-files', '--others', '--exclude-standard', '-z', ...args],
71
+ { cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
72
+ );
73
+ if (status.status !== 0 || diff.status !== 0 || untracked.status !== 0) {
74
+ return {
75
+ head, branch: gitBranch(rootDir), state: 'unavailable', digest: null, files: [],
76
+ };
77
+ }
78
+ const statusText = status.stdout.toString('utf8');
79
+ const files = statusText.split('\0').filter(Boolean);
80
+ const digest = crypto.createHash('sha256');
81
+ digest.update(head).update('\0').update(gitBranch(rootDir) || '').update('\0');
82
+ digest.update(status.stdout).update(diff.stdout);
83
+ for (const relative of untracked.stdout.toString('utf8').split('\0').filter(Boolean).sort()) {
84
+ digest.update(relative).update('\0');
85
+ const file = path.resolve(rootDir, relative);
86
+ if (file.startsWith(`${path.resolve(rootDir)}${path.sep}`)
87
+ && fs.existsSync(file) && fs.statSync(file).isFile()) {
88
+ digest.update(fs.readFileSync(file));
89
+ }
90
+ digest.update('\0');
91
+ }
92
+ return {
93
+ head,
94
+ branch: gitBranch(rootDir),
95
+ state: files.length > 0 ? 'dirty' : 'clean',
96
+ digest: digest.digest('hex'),
97
+ files,
98
+ };
99
+ }
100
+
101
+ function gitWorktreeChanges(rootDir, scope = ['.']) {
102
+ const snapshot = gitWorktreeSnapshot(rootDir, scope);
103
+ return snapshot.state === 'unavailable' ? null : snapshot.files;
104
+ }
105
+
37
106
  function parseJson(value, field, fallback) {
38
107
  if (value == null || value === '') return fallback;
39
108
  try { return JSON.parse(value); }
@@ -51,16 +120,27 @@ function rowToBaseline(row) {
51
120
  evidence: parseJson(row.evidence_json, 'evidence_json', []),
52
121
  verification: parseJson(row.verification_json, 'verification_json', []),
53
122
  unknowns: parseJson(row.unknowns_json, 'unknowns_json', []),
123
+ gaps: parseJson(row.gaps_json, 'gaps_json', []),
124
+ classification: parseJson(row.classification_json, 'classification_json', {}),
125
+ worktree_files: parseJson(row.worktree_files_json, 'worktree_files_json', []),
126
+ worktree_accepted: Boolean(row.worktree_accepted),
54
127
  provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json', {}),
55
128
  };
56
129
  for (const key of [
57
130
  'scope_json', 'spec_refs_json', 'evidence_json', 'verification_json', 'unknowns_json',
58
- 'provider_refs_json',
131
+ 'gaps_json', 'classification_json', 'worktree_files_json', 'provider_refs_json',
59
132
  ]) delete baseline[key];
60
133
  return baseline;
61
134
  }
62
135
 
136
+ function hasTable(db, table) {
137
+ return Boolean(db.prepare(
138
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
139
+ ).get(table));
140
+ }
141
+
63
142
  function readBaseline(db, id) {
143
+ if (!hasTable(db, 'baselines')) return null;
64
144
  const row = id
65
145
  ? db.prepare('SELECT * FROM baselines WHERE id = ?').get(id)
66
146
  : db.prepare(
@@ -75,6 +155,33 @@ function nonEmpty(value, field) {
75
155
  return text;
76
156
  }
77
157
 
158
+ function normalizeReplacementAuthorization(value) {
159
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
160
+ throw new BaselineWorkflowError(
161
+ 'BASELINE_REPLACEMENT_AUTHORIZATION_REQUIRED',
162
+ 'replacing a ready baseline requires approved_by and reason',
163
+ );
164
+ }
165
+ let approvedBy;
166
+ let reason;
167
+ try {
168
+ approvedBy = nonEmpty(value.approved_by, 'replacement_authorization.approved_by');
169
+ reason = nonEmpty(value.reason, 'replacement_authorization.reason');
170
+ } catch {
171
+ throw new BaselineWorkflowError(
172
+ 'BASELINE_REPLACEMENT_AUTHORIZATION_REQUIRED',
173
+ 'replacing a ready baseline requires approved_by and reason',
174
+ );
175
+ }
176
+ if (reason.length < 3) {
177
+ throw new BaselineWorkflowError(
178
+ 'BASELINE_REPLACEMENT_AUTHORIZATION_REQUIRED',
179
+ 'replacement_authorization.reason must explain the owner-approved replacement',
180
+ );
181
+ }
182
+ return { approved_by: approvedBy, reason };
183
+ }
184
+
78
185
  function safeRelativePath(rootDir, candidate, field = 'path') {
79
186
  const value = nonEmpty(candidate, field);
80
187
  if (path.isAbsolute(value)) {
@@ -94,11 +201,27 @@ function normalizeScope(value, rootDir) {
94
201
  throw new BaselineWorkflowError('VALIDATION_ERROR', 'scope must be a non-empty array');
95
202
  }
96
203
  return [...new Set(scope.map((item, index) => {
97
- safeRelativePath(rootDir, item, `scope[${index}]`);
204
+ const resolved = safeRelativePath(rootDir, item, `scope[${index}]`);
205
+ if (!fs.existsSync(resolved)) {
206
+ throw new BaselineWorkflowError(
207
+ 'BASELINE_SCOPE_MISSING', `baseline scope does not exist: ${String(item).trim()}`,
208
+ );
209
+ }
98
210
  return String(item).trim();
99
211
  }))];
100
212
  }
101
213
 
214
+ function storedScopeBlockers(baseline, rootDir) {
215
+ const blockers = [];
216
+ for (const item of baseline.scope) {
217
+ let target;
218
+ try { target = safeRelativePath(rootDir, item, 'scope path'); }
219
+ catch { blockers.push(`BASELINE_SCOPE_INVALID:${item}`); continue; }
220
+ if (!fs.existsSync(target)) blockers.push(`BASELINE_SCOPE_MISSING:${item}`);
221
+ }
222
+ return blockers;
223
+ }
224
+
102
225
  function digestFile(file) {
103
226
  return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
104
227
  }
@@ -133,17 +256,27 @@ function normalizeSpecRefs(value, rootDir) {
133
256
  });
134
257
  }
135
258
 
136
- function normalizeEvidence(value) {
259
+ function normalizeEvidence(value, rootDir) {
137
260
  if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'evidence must be an array');
138
261
  return value.map((item, index) => {
139
262
  if (!item || typeof item !== 'object' || Array.isArray(item) || !EVIDENCE_KINDS.has(item.kind)) {
140
263
  throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid evidence[${index}]`);
141
264
  }
142
- return {
265
+ const normalized = {
143
266
  kind: item.kind,
144
267
  ref: nonEmpty(item.ref, `evidence[${index}].ref`),
145
268
  summary: nonEmpty(item.summary, `evidence[${index}].summary`),
146
269
  };
270
+ if (item.kind === 'source') {
271
+ const file = safeRelativePath(rootDir, normalized.ref, `evidence[${index}].ref`);
272
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
273
+ throw new BaselineWorkflowError(
274
+ 'BASELINE_EVIDENCE_MISSING', `baseline source evidence missing: ${normalized.ref}`,
275
+ );
276
+ }
277
+ normalized.digest = digestFile(file);
278
+ }
279
+ return normalized;
147
280
  });
148
281
  }
149
282
 
@@ -179,6 +312,67 @@ function normalizeUnknowns(value) {
179
312
  });
180
313
  }
181
314
 
315
+ function normalizeGaps(value) {
316
+ if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'gaps must be an array');
317
+ const ids = new Set();
318
+ return value.map((item, index) => {
319
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
320
+ throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid gaps[${index}]`);
321
+ }
322
+ const id = nonEmpty(item.id, `gaps[${index}].id`);
323
+ if (!BASELINE_ID.test(id) || ids.has(id)) {
324
+ throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid or duplicate gap id: ${id}`);
325
+ }
326
+ ids.add(id);
327
+ if (!GAP_CATEGORIES.has(item.category)) {
328
+ throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid gap category: ${item.category}`);
329
+ }
330
+ const status = item.status || 'open';
331
+ if (!GAP_STATUSES.has(status)) {
332
+ throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid gap status: ${status}`);
333
+ }
334
+ const evidenceRefs = item.evidence_refs === undefined ? [] : item.evidence_refs;
335
+ if (!Array.isArray(evidenceRefs) || evidenceRefs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
336
+ throw new BaselineWorkflowError('VALIDATION_ERROR', `gaps[${index}].evidence_refs must be strings`);
337
+ }
338
+ const normalized = {
339
+ id,
340
+ category: item.category,
341
+ status,
342
+ blocking: item.category === 'baseline_blocker' ? true : item.blocking === true,
343
+ summary: nonEmpty(item.summary, `gaps[${index}].summary`),
344
+ evidence_refs: [...new Set(evidenceRefs.map((ref) => ref.trim()))],
345
+ owner: item.owner == null ? null : nonEmpty(item.owner, `gaps[${index}].owner`),
346
+ };
347
+ if (item.resolution != null) {
348
+ normalized.resolution = nonEmpty(item.resolution, `gaps[${index}].resolution`);
349
+ }
350
+ return normalized;
351
+ });
352
+ }
353
+
354
+ function normalizeClassification(value) {
355
+ if (value === undefined || value === null) return {};
356
+ if (typeof value !== 'object' || Array.isArray(value)) {
357
+ throw new BaselineWorkflowError('VALIDATION_ERROR', 'classification must be an object');
358
+ }
359
+ const serialized = JSON.stringify(value);
360
+ if (Buffer.byteLength(serialized) > 32 * 1024) {
361
+ throw new BaselineWorkflowError('VALIDATION_ERROR', 'classification exceeds 32 KiB');
362
+ }
363
+ return JSON.parse(serialized);
364
+ }
365
+
366
+ function summarizeGaps(gaps = []) {
367
+ const summary = { total: gaps.length, open: 0, blocking: 0, by_category: {} };
368
+ for (const gap of gaps) {
369
+ summary.by_category[gap.category] = (summary.by_category[gap.category] || 0) + 1;
370
+ if (gap.status === 'open') summary.open += 1;
371
+ if (gap.status === 'open' && gap.blocking) summary.blocking += 1;
372
+ }
373
+ return summary;
374
+ }
375
+
182
376
  function startBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
183
377
  const id = input.id || 'project-baseline';
184
378
  if (!BASELINE_ID.test(id)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'baseline id is invalid');
@@ -186,36 +380,82 @@ function startBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = tr
186
380
  if (!MODES.has(input.mode)) throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid baseline mode: ${input.mode}`);
187
381
  if (readBaseline(db, id)) throw new BaselineWorkflowError('DUPLICATE_BASELINE_ID', `baseline ${id} exists`);
188
382
  const current = readBaseline(db);
383
+ if (input.replace_migrated === true
384
+ && (!current || current.mode !== 'migrated' || input.mode !== 'brownfield')) {
385
+ throw new BaselineWorkflowError(
386
+ 'BASELINE_REPLACEMENT_INVALID',
387
+ 'replace_migrated requires a current migrated baseline and brownfield mode',
388
+ );
389
+ }
390
+ const replacingMigrated = Boolean(
391
+ current && current.mode === 'migrated' && current.status !== 'superseded'
392
+ && input.replace_migrated === true,
393
+ );
394
+ let replacementAuthorization = null;
395
+ if (input.replace_ready === true) {
396
+ if (!current || current.status !== 'ready' || replacingMigrated) {
397
+ throw new BaselineWorkflowError(
398
+ 'BASELINE_REPLACEMENT_INVALID',
399
+ 'replace_ready requires a current ready baseline',
400
+ );
401
+ }
402
+ replacementAuthorization = normalizeReplacementAuthorization(input.replacement_authorization);
403
+ } else if (input.replacement_authorization !== undefined) {
404
+ throw new BaselineWorkflowError(
405
+ 'BASELINE_REPLACEMENT_INVALID',
406
+ 'replacement_authorization is valid only with replace_ready=true',
407
+ );
408
+ }
189
409
  if (current && current.status !== 'superseded') {
190
- if (current.status === 'ready' && input.replace_ready !== true) {
410
+ if (replacingMigrated) {
411
+ // Compatibility state is never an accepted product baseline. Explicit
412
+ // replacement preserves its audit row while starting real adoption.
413
+ } else if (current.status === 'ready' && input.replace_ready !== true) {
191
414
  throw new BaselineWorkflowError('BASELINE_EXISTS', `ready baseline ${current.id} requires replace_ready=true`);
192
- }
193
- if (current.status !== 'ready') {
415
+ } else if (current.status !== 'ready') {
194
416
  throw new BaselineWorkflowError('BASELINE_IN_PROGRESS', `baseline ${current.id} is ${current.status}`);
195
417
  }
196
418
  }
197
419
  const scope = normalizeScope(input.scope, rootDir);
198
420
  const providers = providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
421
+ const classification = normalizeClassification(input.classification);
422
+ const snapshot = gitWorktreeSnapshot(rootDir, scope);
423
+ if (snapshot.head && input.repository_revision && input.repository_revision !== snapshot.head) {
424
+ throw new BaselineWorkflowError(
425
+ 'BASELINE_REVISION_MISMATCH', 'repository_revision does not match current Git HEAD',
426
+ );
427
+ }
199
428
  const status = input.mode === 'brownfield' ? 'adopting' : 'draft';
200
429
  const ts = nowIso();
201
430
  return ops.tx(db, () => {
202
- if (current?.status === 'ready') {
431
+ if (current && (current.status === 'ready' || replacingMigrated)) {
203
432
  db.prepare("UPDATE baselines SET status = 'superseded', updated_at = ? WHERE id = ?")
204
433
  .run(ts, current.id);
205
434
  }
206
435
  db.prepare(
207
436
  `INSERT INTO baselines
208
437
  (id, project_name, project_type, stack, mode, status, repository_root, scope_json,
209
- repository_revision, provider_refs_json, started_at, updated_at)
210
- VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?)`,
438
+ repository_revision, repository_branch, worktree_state, worktree_digest,
439
+ worktree_files_json, classification_json, provider_refs_json, started_at, updated_at)
440
+ VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
211
441
  ).run(
212
442
  id, projectName, input.project_type || null, input.stack || null, input.mode, status,
213
- JSON.stringify(scope), input.repository_revision || gitHead(rootDir), JSON.stringify(providers), ts, ts,
443
+ JSON.stringify(scope), snapshot.head || input.repository_revision || null, snapshot.branch,
444
+ snapshot.state, snapshot.digest, JSON.stringify(snapshot.files), JSON.stringify(classification),
445
+ JSON.stringify(providers), ts, ts,
214
446
  );
215
447
  if (emitEvent) {
448
+ const payload = {
449
+ baseline_id: id,
450
+ mode: input.mode,
451
+ replaced_baseline_id: current?.id || null,
452
+ };
453
+ if (replacementAuthorization) {
454
+ payload.replacement_authorization = { ...replacementAuthorization, recorded_at: ts };
455
+ }
216
456
  ops.appendEventInTx(db, {
217
457
  type: 'baseline_started',
218
- payload: { baseline_id: id, mode: input.mode, replaced_baseline_id: current?.id || null },
458
+ payload,
219
459
  });
220
460
  }
221
461
  return readBaseline(db, id);
@@ -228,33 +468,50 @@ function recordBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = t
228
468
  if (['ready', 'superseded'].includes(current.status)) {
229
469
  throw new BaselineWorkflowError('BASELINE_NOT_MUTABLE', `baseline ${current.id} is ${current.status}`);
230
470
  }
231
- const scope = input.scope === undefined ? current.scope : normalizeScope(input.scope, rootDir);
471
+ const scope = normalizeScope(input.scope === undefined ? current.scope : input.scope, rootDir);
232
472
  const specs = input.spec_refs === undefined ? current.spec_refs : normalizeSpecRefs(input.spec_refs, rootDir);
233
- const evidence = input.evidence === undefined ? current.evidence : normalizeEvidence(input.evidence);
473
+ const evidence = input.evidence === undefined
474
+ ? current.evidence : normalizeEvidence(input.evidence, rootDir);
234
475
  const verification = input.verification === undefined
235
476
  ? current.verification : normalizeVerification(input.verification);
236
477
  const unknowns = input.unknowns === undefined ? current.unknowns : normalizeUnknowns(input.unknowns);
478
+ const gaps = input.gaps === undefined ? current.gaps : normalizeGaps(input.gaps);
479
+ const classification = input.classification === undefined
480
+ ? current.classification : normalizeClassification(input.classification);
237
481
  const providers = input.provider_refs === undefined
238
482
  ? current.provider_refs
239
483
  : providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
240
- const revision = input.repository_revision === undefined
241
- ? (current.repository_revision || gitHead(rootDir)
242
- || workspaceRevision({ scope, specs, evidence }, rootDir))
243
- : nonEmpty(input.repository_revision, 'repository_revision');
484
+ const snapshot = gitWorktreeSnapshot(rootDir, scope);
485
+ if (snapshot.head && input.repository_revision && input.repository_revision !== snapshot.head) {
486
+ throw new BaselineWorkflowError(
487
+ 'BASELINE_REVISION_MISMATCH', 'repository_revision does not match current Git HEAD',
488
+ );
489
+ }
490
+ const revision = snapshot.head || (input.repository_revision === undefined
491
+ ? (current.repository_revision || workspaceRevision({ scope, specs, evidence }, rootDir))
492
+ : nonEmpty(input.repository_revision, 'repository_revision'));
244
493
  const status = current.mode === 'brownfield' ? 'adopting' : 'draft';
245
494
  const ts = nowIso();
246
495
  return ops.tx(db, () => {
247
496
  db.prepare(
248
- `UPDATE baselines SET status = ?, scope_json = ?, repository_revision = ?, spec_refs_json = ?,
249
- evidence_json = ?, verification_json = ?, unknowns_json = ?, provider_refs_json = ?,
497
+ `UPDATE baselines SET status = ?, scope_json = ?, repository_revision = ?,
498
+ repository_branch = ?, worktree_state = ?, worktree_digest = ?, worktree_files_json = ?,
499
+ worktree_accepted = 0, spec_refs_json = ?, evidence_json = ?, verification_json = ?,
500
+ unknowns_json = ?, gaps_json = ?, classification_json = ?, provider_refs_json = ?,
250
501
  approved_by = NULL, approval_note = NULL, converged_at = NULL, updated_at = ? WHERE id = ?`,
251
502
  ).run(
252
- status, JSON.stringify(scope), revision, JSON.stringify(specs), JSON.stringify(evidence),
253
- JSON.stringify(verification), JSON.stringify(unknowns), JSON.stringify(providers), ts, current.id,
503
+ status, JSON.stringify(scope), revision, snapshot.branch, snapshot.state, snapshot.digest,
504
+ JSON.stringify(snapshot.files), JSON.stringify(specs), JSON.stringify(evidence),
505
+ JSON.stringify(verification), JSON.stringify(unknowns), JSON.stringify(gaps),
506
+ JSON.stringify(classification), JSON.stringify(providers), ts, current.id,
254
507
  );
255
508
  if (emitEvent) {
256
509
  ops.appendEventInTx(db, {
257
- type: 'baseline_recorded', payload: { baseline_id: current.id, spec_count: specs.length },
510
+ type: 'baseline_recorded',
511
+ payload: {
512
+ baseline_id: current.id, spec_count: specs.length,
513
+ gap_count: gaps.length, worktree_state: snapshot.state,
514
+ },
258
515
  });
259
516
  }
260
517
  return readBaseline(db, current.id);
@@ -276,18 +533,47 @@ function storedSpecBlockers(baseline, rootDir) {
276
533
  return blockers;
277
534
  }
278
535
 
536
+ function storedEvidenceBlockers(baseline, rootDir) {
537
+ const blockers = [];
538
+ for (const item of baseline.evidence) {
539
+ if (item.kind !== 'source' && !item.digest) continue;
540
+ let file;
541
+ try { file = safeRelativePath(rootDir, item.ref, 'evidence ref'); }
542
+ catch { blockers.push(`BASELINE_EVIDENCE_INVALID:${item.ref}`); continue; }
543
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
544
+ blockers.push(`BASELINE_EVIDENCE_MISSING:${item.ref}`);
545
+ } else if (!item.digest) {
546
+ blockers.push(`BASELINE_EVIDENCE_UNVERIFIED:${item.ref}`);
547
+ } else if (digestFile(file) !== item.digest) {
548
+ blockers.push(`BASELINE_EVIDENCE_STALE:${item.ref}`);
549
+ }
550
+ }
551
+ return blockers;
552
+ }
553
+
279
554
  function convergenceBlockers(baseline, input, rootDir) {
280
555
  const blockers = new Set();
281
556
  const expected = typeof input.expected_revision === 'string' ? input.expected_revision.trim() : '';
282
557
  if (!expected) blockers.add('BASELINE_REVISION_REQUIRED');
283
558
  else if (baseline.repository_revision !== expected) blockers.add('BASELINE_REVISION_MISMATCH');
284
- const head = gitHead(rootDir);
285
- if (head && expected && head !== expected) blockers.add('BASELINE_HEAD_STALE');
559
+ const snapshot = gitWorktreeSnapshot(rootDir, baseline.scope);
560
+ if (snapshot.head && expected && snapshot.head !== expected) blockers.add('BASELINE_HEAD_STALE');
561
+ if (baseline.repository_branch && snapshot.branch
562
+ && baseline.repository_branch !== snapshot.branch) blockers.add('BASELINE_BRANCH_STALE');
286
563
  if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) blockers.add('BASELINE_SCOPE_MISSING');
287
564
  for (const kind of ['product', 'architecture']) {
288
565
  if (!baseline.spec_refs.some((ref) => ref.kind === kind)) blockers.add(`BASELINE_SPEC_MISSING:${kind}`);
289
566
  }
290
567
  for (const blocker of storedSpecBlockers(baseline, rootDir)) blockers.add(blocker);
568
+ for (const blocker of storedEvidenceBlockers(baseline, rootDir)) blockers.add(blocker);
569
+ for (const blocker of storedScopeBlockers(baseline, rootDir)) blockers.add(blocker);
570
+ if (baseline.worktree_digest && snapshot.digest && baseline.worktree_digest !== snapshot.digest) {
571
+ blockers.add('BASELINE_WORKTREE_STALE');
572
+ if (snapshot.state === 'dirty') blockers.add('BASELINE_WORKTREE_DIRTY');
573
+ }
574
+ if (baseline.worktree_state === 'dirty' && input.accept_dirty_worktree !== true) {
575
+ blockers.add('BASELINE_DIRTY_WORKTREE_NOT_ACCEPTED');
576
+ }
291
577
  if (baseline.evidence.length === 0) blockers.add('BASELINE_EVIDENCE_MISSING');
292
578
  if (baseline.mode === 'brownfield' && !baseline.evidence.some((item) => item.kind === 'source')) {
293
579
  blockers.add('BASELINE_SOURCE_EVIDENCE_MISSING');
@@ -303,6 +589,9 @@ function convergenceBlockers(baseline, input, rootDir) {
303
589
  for (const item of baseline.unknowns.filter((unknown) => unknown.blocking)) {
304
590
  blockers.add(`BASELINE_UNKNOWN_BLOCKING:${item.summary}`);
305
591
  }
592
+ for (const gap of baseline.gaps.filter((item) => item.status === 'open' && item.blocking)) {
593
+ blockers.add(`BASELINE_GAP_BLOCKING:${gap.id}`);
594
+ }
306
595
  if (!String(input.approved_by || '').trim()) blockers.add('BASELINE_APPROVER_REQUIRED');
307
596
  if (String(input.approval_note || '').trim().length < 3) blockers.add('BASELINE_APPROVAL_NOTE_REQUIRED');
308
597
  return [...blockers].sort();
@@ -331,8 +620,11 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
331
620
  ops.tx(db, () => {
332
621
  db.prepare(
333
622
  `UPDATE baselines SET status = 'ready', approved_by = ?, approval_note = ?,
334
- converged_at = ?, updated_at = ? WHERE id = ?`,
335
- ).run(String(input.approved_by).trim(), String(input.approval_note).trim(), ts, ts, current.id);
623
+ worktree_accepted = ?, converged_at = ?, updated_at = ? WHERE id = ?`,
624
+ ).run(
625
+ String(input.approved_by).trim(), String(input.approval_note).trim(),
626
+ input.accept_dirty_worktree === true ? 1 : 0, ts, ts, current.id,
627
+ );
336
628
  if (emitEvent) {
337
629
  ops.appendEventInTx(db, {
338
630
  type: 'baseline_converged', payload: { baseline_id: current.id, revision: current.repository_revision },
@@ -343,24 +635,50 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
343
635
  }
344
636
 
345
637
  function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
638
+ if (!hasTable(db, 'baselines')) {
639
+ return {
640
+ status: 'fail', blockers: ['BASELINE_SCHEMA_MIGRATION_REQUIRED'], warnings: [], baseline: null,
641
+ };
642
+ }
346
643
  const baseline = readBaseline(db, id);
347
644
  if (!baseline) {
348
645
  return { status: 'fail', blockers: ['BASELINE_MISSING'], warnings: [], baseline: null };
349
646
  }
350
- if (baseline.status !== 'ready') {
647
+ if (baseline.mode === 'migrated') {
351
648
  return {
352
- status: 'fail', blockers: [`BASELINE_NOT_READY:${baseline.status}`], warnings: [], baseline,
649
+ status: 'fail', blockers: ['BASELINE_MIGRATION_REVIEW_REQUIRED'], warnings: [], baseline,
353
650
  };
354
651
  }
355
- if (baseline.mode === 'migrated') {
652
+ if (baseline.status !== 'ready') {
356
653
  return {
357
- status: 'pass', blockers: [], warnings: ['BASELINE_MIGRATION_REVIEW_RECOMMENDED'], baseline,
654
+ status: 'fail', blockers: [`BASELINE_NOT_READY:${baseline.status}`], warnings: [], baseline,
358
655
  };
359
656
  }
360
- const blockers = storedSpecBlockers(baseline, rootDir);
361
- const head = gitHead(rootDir);
362
- if (head && baseline.repository_revision && head !== baseline.repository_revision) {
657
+ const blockers = [
658
+ ...storedScopeBlockers(baseline, rootDir),
659
+ ...storedSpecBlockers(baseline, rootDir),
660
+ ...storedEvidenceBlockers(baseline, rootDir),
661
+ ];
662
+ const snapshot = gitWorktreeSnapshot(rootDir, baseline.scope);
663
+ if (snapshot.head && baseline.repository_revision && snapshot.head !== baseline.repository_revision) {
363
664
  blockers.push('BASELINE_HEAD_STALE');
665
+ } else if (!snapshot.head && baseline.repository_revision?.startsWith('workspace:')) {
666
+ const revision = workspaceRevision({
667
+ scope: baseline.scope, specs: baseline.spec_refs, evidence: baseline.evidence,
668
+ }, rootDir);
669
+ if (revision !== baseline.repository_revision) blockers.push('BASELINE_WORKSPACE_STALE');
670
+ }
671
+ if (baseline.repository_branch && snapshot.branch
672
+ && baseline.repository_branch !== snapshot.branch) blockers.push('BASELINE_BRANCH_STALE');
673
+ if (baseline.worktree_digest && snapshot.digest && baseline.worktree_digest !== snapshot.digest) {
674
+ blockers.push('BASELINE_WORKTREE_STALE');
675
+ if (snapshot.state === 'dirty') blockers.push('BASELINE_WORKTREE_DIRTY');
676
+ }
677
+ if (baseline.worktree_state === 'dirty' && !baseline.worktree_accepted) {
678
+ blockers.push('BASELINE_DIRTY_WORKTREE_NOT_ACCEPTED');
679
+ }
680
+ for (const gap of baseline.gaps.filter((item) => item.status === 'open' && item.blocking)) {
681
+ blockers.push(`BASELINE_GAP_BLOCKING:${gap.id}`);
364
682
  }
365
683
  return { status: blockers.length === 0 ? 'pass' : 'fail', blockers, warnings: [], baseline };
366
684
  }
@@ -379,19 +697,40 @@ function reconcileBaseline(db, { baseline_updates = [], change_id = null } = {},
379
697
  }
380
698
  if (current.mode === 'migrated' && current.spec_refs.length === 0) return current;
381
699
  const refs = new Map(current.spec_refs.map((ref) => [ref.path, ref]));
700
+ const evidence = current.evidence.map((item) => ({ ...item }));
382
701
  for (const update of baseline_updates) {
383
702
  const file = safeRelativePath(rootDir, update, 'baseline update');
384
703
  if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
385
704
  throw new BaselineWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${update}`);
386
705
  }
387
- refs.set(update, { kind: refs.get(update)?.kind || inferSpecKind(update), path: update, digest: digestFile(file) });
706
+ const digest = digestFile(file);
707
+ const existingRef = refs.get(update);
708
+ if (existingRef) refs.set(update, { ...existingRef, digest });
709
+ let evidenceMatched = false;
710
+ for (const item of evidence) {
711
+ if (item.ref !== update || (item.kind !== 'source' && !item.digest)) continue;
712
+ item.digest = digest;
713
+ evidenceMatched = true;
714
+ }
715
+ if (!existingRef && !evidenceMatched) {
716
+ refs.set(update, { kind: inferSpecKind(update), path: update, digest });
717
+ }
388
718
  }
389
- const revision = gitHead(rootDir) || current.repository_revision;
719
+ const specs = [...refs.values()];
720
+ const snapshot = gitWorktreeSnapshot(rootDir, current.scope);
721
+ const revision = snapshot.head || workspaceRevision({
722
+ scope: current.scope, specs, evidence,
723
+ }, rootDir);
390
724
  const ts = nowIso();
391
725
  ops.tx(db, () => {
392
726
  db.prepare(
393
- 'UPDATE baselines SET repository_revision = ?, spec_refs_json = ?, updated_at = ? WHERE id = ?',
394
- ).run(revision, JSON.stringify([...refs.values()]), ts, current.id);
727
+ `UPDATE baselines SET repository_revision = ?, repository_branch = ?, worktree_state = ?,
728
+ worktree_digest = ?, worktree_files_json = ?, worktree_accepted = 0,
729
+ spec_refs_json = ?, evidence_json = ?, updated_at = ? WHERE id = ?`,
730
+ ).run(
731
+ revision, snapshot.branch, snapshot.state, snapshot.digest, JSON.stringify(snapshot.files),
732
+ JSON.stringify(specs), JSON.stringify(evidence), ts, current.id,
733
+ );
395
734
  if (emitEvent) {
396
735
  ops.appendEventInTx(db, {
397
736
  type: 'baseline_reconciled',
@@ -403,6 +742,26 @@ function reconcileBaseline(db, { baseline_updates = [], change_id = null } = {},
403
742
  return readBaseline(db, current.id);
404
743
  }
405
744
 
745
+ function appendGapInTx(db, input = {}, { emitEvent = true } = {}) {
746
+ const current = readBaseline(db, input.baseline_id);
747
+ if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', 'current baseline not found');
748
+ const next = normalizeGaps([...current.gaps.filter((gap) => gap.id !== input.gap?.id), input.gap]);
749
+ const ts = nowIso();
750
+ db.prepare('UPDATE baselines SET gaps_json = ?, updated_at = ? WHERE id = ?')
751
+ .run(JSON.stringify(next), ts, current.id);
752
+ if (emitEvent) {
753
+ ops.appendEventInTx(db, {
754
+ type: 'baseline_gap_recorded',
755
+ payload: { baseline_id: current.id, gap_id: input.gap.id, category: input.gap.category },
756
+ });
757
+ }
758
+ return readBaseline(db, current.id);
759
+ }
760
+
761
+ function appendGap(db, input = {}, options = {}) {
762
+ return ops.tx(db, () => appendGapInTx(db, input, options));
763
+ }
764
+
406
765
  module.exports = {
407
766
  BaselineWorkflowError,
408
767
  startBaseline,
@@ -412,4 +771,11 @@ module.exports = {
412
771
  inspectBaseline,
413
772
  reconcileBaseline,
414
773
  gitHead,
774
+ gitBranch,
775
+ gitWorktreeSnapshot,
776
+ gitWorktreeChanges,
777
+ appendGap,
778
+ appendGapInTx,
779
+ summarizeGaps,
780
+ normalizeGaps,
415
781
  };