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
|
@@ -8,12 +8,11 @@ const { spawnSync } = require('node:child_process');
|
|
|
8
8
|
const ops = require('./state-ops.cjs');
|
|
9
9
|
const contextSpine = require('./context-spine.cjs');
|
|
10
10
|
const specLearning = require('./spec-learning.cjs');
|
|
11
|
+
const providerRefs = require('./provider-refs.cjs');
|
|
12
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
13
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
11
14
|
|
|
12
15
|
const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
13
|
-
const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
|
|
14
|
-
const PROVIDER_FIELDS = new Set([
|
|
15
|
-
'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
|
|
16
|
-
]);
|
|
17
16
|
const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
|
|
18
17
|
const CHANGE_TRANSITIONS = Object.freeze({
|
|
19
18
|
active: new Set(['active', 'blocked', 'cancelled']),
|
|
@@ -62,9 +61,12 @@ function rowToChange(row) {
|
|
|
62
61
|
...row,
|
|
63
62
|
docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
|
|
64
63
|
provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
|
|
64
|
+
baseline_bypass: row.baseline_bypass_json
|
|
65
|
+
? parseJson(row.baseline_bypass_json, 'baseline_bypass_json') : null,
|
|
65
66
|
};
|
|
66
67
|
delete change.docs_impact_json;
|
|
67
68
|
delete change.provider_refs_json;
|
|
69
|
+
delete change.baseline_bypass_json;
|
|
68
70
|
return change;
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -83,29 +85,30 @@ function normalizeDocsImpact(value) {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
function normalizeProviderRefs(value) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
for (const key of Object.keys(ref)) {
|
|
96
|
-
if (!PROVIDER_FIELDS.has(key)) {
|
|
97
|
-
throw new ChangeWorkflowError(
|
|
98
|
-
'PROVIDER_CONTENT_FORBIDDEN',
|
|
99
|
-
`provider_refs.${kind}.${key} is not metadata; provider content remains external`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
|
|
104
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
|
|
105
|
-
}
|
|
106
|
-
out[kind] = { ...ref };
|
|
88
|
+
return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeBaselineBypass(value) {
|
|
92
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
93
|
+
throw new ChangeWorkflowError(
|
|
94
|
+
'BASELINE_BYPASS_REQUIRED',
|
|
95
|
+
'incident work without a ready baseline requires an explicit baseline_bypass record',
|
|
96
|
+
);
|
|
107
97
|
}
|
|
108
|
-
|
|
98
|
+
const reason = typeof value.reason === 'string' ? value.reason.trim() : '';
|
|
99
|
+
const approvedBy = typeof value.approved_by === 'string' ? value.approved_by.trim() : '';
|
|
100
|
+
if (reason.length < 3 || !approvedBy) {
|
|
101
|
+
throw new ChangeWorkflowError(
|
|
102
|
+
'BASELINE_BYPASS_REQUIRED',
|
|
103
|
+
'baseline_bypass requires reason and approved_by',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
mode: 'incident_break_glass',
|
|
108
|
+
reason,
|
|
109
|
+
approved_by: approvedBy,
|
|
110
|
+
recorded_at: nowIso(),
|
|
111
|
+
};
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
function gitHead(rootDir) {
|
|
@@ -155,6 +158,14 @@ function writeIntent(file, change) {
|
|
|
155
158
|
'',
|
|
156
159
|
];
|
|
157
160
|
if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
|
|
161
|
+
if (change.baseline_bypass) {
|
|
162
|
+
lines.push(
|
|
163
|
+
'## Baseline break-glass', '',
|
|
164
|
+
`- Approved by: ${change.baseline_bypass.approved_by}`,
|
|
165
|
+
`- Recorded at: ${change.baseline_bypass.recorded_at}`,
|
|
166
|
+
'', change.baseline_bypass.reason, '',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
158
169
|
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
159
170
|
}
|
|
160
171
|
|
|
@@ -243,6 +254,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
243
254
|
change: {
|
|
244
255
|
id: change.id, title: change.title, kind: change.kind, status: change.status,
|
|
245
256
|
intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
|
|
257
|
+
baseline_bypass: change.baseline_bypass,
|
|
246
258
|
},
|
|
247
259
|
git: { head },
|
|
248
260
|
tasks,
|
|
@@ -254,6 +266,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
254
266
|
readiness: spine.readiness,
|
|
255
267
|
context: spine.context,
|
|
256
268
|
execution_contract: spine.execution_contract,
|
|
269
|
+
baseline: spine.baseline,
|
|
257
270
|
resume: spine.resume,
|
|
258
271
|
providers,
|
|
259
272
|
provider_boundary: 'metadata references only; memory and code graph content remain external',
|
|
@@ -322,7 +335,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
322
335
|
if (!REQUIRED_EVIDENCE[input.kind]) {
|
|
323
336
|
throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
|
|
324
337
|
}
|
|
338
|
+
if (input.baseline_bypass !== undefined && input.kind !== 'incident') {
|
|
339
|
+
throw new ChangeWorkflowError(
|
|
340
|
+
'VALIDATION_ERROR', 'baseline_bypass is valid only for incident changes',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
325
343
|
if (readChange(db, input.id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${input.id} exists`);
|
|
344
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
345
|
+
let baselineBypass = null;
|
|
346
|
+
if (baselineHealth.status !== 'pass') {
|
|
347
|
+
if (input.kind !== 'incident') {
|
|
348
|
+
throw new ChangeWorkflowError(
|
|
349
|
+
'BASELINE_NOT_READY',
|
|
350
|
+
`ordinary changes require a ready baseline: ${baselineHealth.blockers.join(', ')}`,
|
|
351
|
+
{ blockers: baselineHealth.blockers },
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
baselineBypass = normalizeBaselineBypass(input.baseline_bypass);
|
|
355
|
+
} else if (input.baseline_bypass !== undefined) {
|
|
356
|
+
throw new ChangeWorkflowError(
|
|
357
|
+
'VALIDATION_ERROR', 'baseline_bypass is not valid while the project baseline is healthy',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
326
360
|
const docsImpact = normalizeDocsImpact(input.docs_impact);
|
|
327
361
|
const providers = normalizeProviderRefs(input.provider_refs);
|
|
328
362
|
const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
|
|
@@ -333,7 +367,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
333
367
|
const ts = nowIso();
|
|
334
368
|
const row = {
|
|
335
369
|
id: input.id, title, kind: input.kind, status: 'active',
|
|
336
|
-
intent, docs_impact: docsImpact, provider_refs: providers,
|
|
370
|
+
intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
|
|
337
371
|
base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
|
|
338
372
|
created_at: ts, updated_at: ts,
|
|
339
373
|
};
|
|
@@ -342,11 +376,13 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
342
376
|
db.prepare(
|
|
343
377
|
`INSERT INTO changes
|
|
344
378
|
(id, title, kind, status, intent, docs_impact_json, provider_refs_json,
|
|
345
|
-
base_commit, artifact_root, created_at, updated_at)
|
|
346
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
379
|
+
baseline_bypass_json, base_commit, artifact_root, created_at, updated_at)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
347
381
|
).run(
|
|
348
382
|
row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
|
|
349
|
-
JSON.stringify(row.provider_refs), row.
|
|
383
|
+
JSON.stringify(row.provider_refs), row.baseline_bypass
|
|
384
|
+
? JSON.stringify(row.baseline_bypass) : null,
|
|
385
|
+
row.base_commit, row.artifact_root, ts, ts,
|
|
350
386
|
);
|
|
351
387
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
352
388
|
const intentPath = path.join(artifactDir, 'intent.md');
|
|
@@ -382,6 +418,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
382
418
|
}
|
|
383
419
|
}
|
|
384
420
|
|
|
421
|
+
function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
422
|
+
const change = input.change_id ? readChange(db, input.change_id) : null;
|
|
423
|
+
if (input.change_id && !change) {
|
|
424
|
+
throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.change_id} not found`);
|
|
425
|
+
}
|
|
426
|
+
if (change && !['active', 'blocked'].includes(change.status)) {
|
|
427
|
+
throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${change.id} is ${change.status}`);
|
|
428
|
+
}
|
|
429
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
430
|
+
if (baselineHealth.status === 'pass') return;
|
|
431
|
+
if (change) {
|
|
432
|
+
// The change writer gate already proved that this work either started from
|
|
433
|
+
// healthy authority or carries an approved incident break-glass record.
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
throw new ChangeWorkflowError(
|
|
437
|
+
'BASELINE_NOT_READY',
|
|
438
|
+
`ordinary task creation requires a ready baseline or an already-authorized active change: ${baselineHealth.blockers.join(', ')}`,
|
|
439
|
+
{ blockers: baselineHealth.blockers, change_id: input.change_id || null },
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
385
443
|
function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
|
|
386
444
|
const current = readChange(db, id);
|
|
387
445
|
if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
|
|
@@ -504,6 +562,17 @@ function evidenceBlockers(change, evidence, tasks) {
|
|
|
504
562
|
return blockers;
|
|
505
563
|
}
|
|
506
564
|
|
|
565
|
+
function baselineConvergenceBlockers(db, change, rootDir) {
|
|
566
|
+
const baseline = baselines.readBaseline(db);
|
|
567
|
+
if (baseline && baseline.status === 'ready' && baseline.mode !== 'migrated') return [];
|
|
568
|
+
if (change.kind === 'incident' && change.baseline_bypass?.mode === 'incident_break_glass') {
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
if (!baseline) return ['BASELINE_MISSING'];
|
|
572
|
+
if (baseline.mode === 'migrated') return ['BASELINE_MIGRATION_REVIEW_REQUIRED'];
|
|
573
|
+
return [`BASELINE_NOT_READY:${baseline.status}`];
|
|
574
|
+
}
|
|
575
|
+
|
|
507
576
|
function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
508
577
|
const change = readChange(db, input.id);
|
|
509
578
|
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
|
|
@@ -512,6 +581,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
512
581
|
}
|
|
513
582
|
const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
|
|
514
583
|
const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
|
|
584
|
+
for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
|
|
515
585
|
if (tasks.length === 0) blockers.add('NO_TASKS');
|
|
516
586
|
if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
|
|
517
587
|
if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
|
|
@@ -608,12 +678,98 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
608
678
|
return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
|
|
609
679
|
}
|
|
610
680
|
|
|
681
|
+
function finalizeArchive(db, intent, { rootDir }) {
|
|
682
|
+
const change = readChange(db, intent.change_id);
|
|
683
|
+
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${intent.change_id} not found`);
|
|
684
|
+
const destination = safeRelativePath(rootDir, intent.destination);
|
|
685
|
+
const relative = path.relative(rootDir, destination);
|
|
686
|
+
if (change.status === 'archived' && change.artifact_root === relative) {
|
|
687
|
+
return { change, archive_path: destination };
|
|
688
|
+
}
|
|
689
|
+
if (change.status !== 'ready') {
|
|
690
|
+
throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${change.id} must be ready to finish archive`);
|
|
691
|
+
}
|
|
692
|
+
if (!fs.existsSync(destination)) {
|
|
693
|
+
throw new ChangeWorkflowError('ARCHIVE_DESTINATION_MISSING', `archive destination missing: ${relative}`);
|
|
694
|
+
}
|
|
695
|
+
const baselineHealthBefore = baselines.inspectBaseline(db, { rootDir });
|
|
696
|
+
const breakGlass = Boolean(
|
|
697
|
+
baselineHealthBefore.status !== 'pass'
|
|
698
|
+
&& change.kind === 'incident'
|
|
699
|
+
&& change.baseline_bypass?.mode === 'incident_break_glass',
|
|
700
|
+
);
|
|
701
|
+
ops.tx(db, () => {
|
|
702
|
+
db.prepare(
|
|
703
|
+
"UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
|
|
704
|
+
).run(relative, nowIso(), nowIso(), change.id);
|
|
705
|
+
const prefix = `${intent.source}${path.sep}`;
|
|
706
|
+
const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?').all(change.id);
|
|
707
|
+
for (const artifact of artifactRows) {
|
|
708
|
+
if (artifact.path !== intent.source && !artifact.path.startsWith(prefix)) continue;
|
|
709
|
+
const suffix = path.relative(intent.source, artifact.path);
|
|
710
|
+
const archivedPath = suffix ? path.join(relative, suffix) : relative;
|
|
711
|
+
db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
|
|
712
|
+
.run(archivedPath, nowIso(), artifact.id);
|
|
713
|
+
}
|
|
714
|
+
const archivedSummary = path.join(destination, 'archive-summary.md');
|
|
715
|
+
upsertArtifact(db, {
|
|
716
|
+
change_id: change.id, kind: 'archive_summary',
|
|
717
|
+
artifactPath: path.relative(rootDir, archivedSummary),
|
|
718
|
+
contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
|
|
719
|
+
metadata: {
|
|
720
|
+
baseline_updates: intent.baseline_updates,
|
|
721
|
+
no_baseline_change_reason: intent.no_baseline_change_reason,
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
|
|
725
|
+
.run(nowIso(), change.id);
|
|
726
|
+
if (breakGlass) {
|
|
727
|
+
const currentBaseline = baselines.readBaseline(db);
|
|
728
|
+
if (currentBaseline) {
|
|
729
|
+
baselines.appendGapInTx(db, {
|
|
730
|
+
baseline_id: currentBaseline.id,
|
|
731
|
+
gap: {
|
|
732
|
+
id: `incident-${change.id}-reconciliation`,
|
|
733
|
+
category: 'baseline_blocker',
|
|
734
|
+
status: 'open',
|
|
735
|
+
blocking: true,
|
|
736
|
+
summary: `Incident ${change.id} was archived under break-glass and requires baseline reconciliation.`,
|
|
737
|
+
evidence_refs: [relative],
|
|
738
|
+
owner: change.baseline_bypass.approved_by,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
baselines.reconcileBaseline(db, {
|
|
744
|
+
baseline_updates: intent.baseline_updates, change_id: change.id,
|
|
745
|
+
}, { rootDir });
|
|
746
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
747
|
+
if (baselineHealth.blockers.length > 0) {
|
|
748
|
+
throw new ChangeWorkflowError(
|
|
749
|
+
'BASELINE_RECONCILIATION_INCOMPLETE',
|
|
750
|
+
`baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
|
|
751
|
+
{ blockers: baselineHealth.blockers },
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
ops.appendEventInTx(db, {
|
|
756
|
+
type: 'change_archived', change_id: change.id,
|
|
757
|
+
payload: {
|
|
758
|
+
archive_path: relative, baseline_updates: intent.baseline_updates,
|
|
759
|
+
no_baseline_change_reason: intent.no_baseline_change_reason,
|
|
760
|
+
baseline_bypass: breakGlass,
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
change: readChange(db, change.id), archive_path: destination,
|
|
766
|
+
baseline_bypass: breakGlass,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
611
770
|
function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
612
771
|
const change = readChange(db, input.id);
|
|
613
772
|
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
|
|
614
|
-
if (change.status !== 'ready') {
|
|
615
|
-
throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
|
|
616
|
-
}
|
|
617
773
|
if (!input.summary || String(input.summary).trim().length < 3) {
|
|
618
774
|
throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
|
|
619
775
|
}
|
|
@@ -627,62 +783,74 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
627
783
|
throw new ChangeWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${file}`);
|
|
628
784
|
}
|
|
629
785
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath) : null;
|
|
639
|
-
fs.writeFileSync(summaryPath, [
|
|
640
|
-
`# Archived change: ${change.title}`, '', String(input.summary).trim(), '',
|
|
641
|
-
'## Baseline reconciliation', '',
|
|
642
|
-
...(updates.length > 0 ? updates.map((file) => `- ${file}`) : [noChangeReason]), '',
|
|
643
|
-
].join('\n'));
|
|
644
|
-
fs.renameSync(source, destination);
|
|
786
|
+
if (!['ready', 'archived'].includes(change.status)) {
|
|
787
|
+
throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
|
|
788
|
+
}
|
|
789
|
+
const prepared = archiveJournal.prepareArchiveMove({
|
|
790
|
+
rootDir, change, summary: String(input.summary), baselineUpdates: updates,
|
|
791
|
+
noBaselineChangeReason: noChangeReason || null,
|
|
792
|
+
});
|
|
793
|
+
let result;
|
|
645
794
|
try {
|
|
646
|
-
|
|
647
|
-
const relative = path.relative(rootDir, destination);
|
|
648
|
-
db.prepare(
|
|
649
|
-
"UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
|
|
650
|
-
).run(relative, nowIso(), nowIso(), change.id);
|
|
651
|
-
const prefix = `${change.artifact_root}${path.sep}`;
|
|
652
|
-
const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?')
|
|
653
|
-
.all(change.id);
|
|
654
|
-
for (const artifact of artifactRows) {
|
|
655
|
-
if (artifact.path !== change.artifact_root && !artifact.path.startsWith(prefix)) continue;
|
|
656
|
-
const suffix = path.relative(change.artifact_root, artifact.path);
|
|
657
|
-
const archivedPath = suffix ? path.join(relative, suffix) : relative;
|
|
658
|
-
db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
|
|
659
|
-
.run(archivedPath, nowIso(), artifact.id);
|
|
660
|
-
}
|
|
661
|
-
const archivedSummary = path.join(destination, 'archive-summary.md');
|
|
662
|
-
upsertArtifact(db, {
|
|
663
|
-
change_id: change.id,
|
|
664
|
-
kind: 'archive_summary',
|
|
665
|
-
artifactPath: path.relative(rootDir, archivedSummary),
|
|
666
|
-
contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
|
|
667
|
-
metadata: {
|
|
668
|
-
baseline_updates: updates,
|
|
669
|
-
no_baseline_change_reason: noChangeReason || null,
|
|
670
|
-
},
|
|
671
|
-
});
|
|
672
|
-
db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
|
|
673
|
-
.run(nowIso(), change.id);
|
|
674
|
-
ops.appendEventInTx(db, {
|
|
675
|
-
type: 'change_archived', change_id: change.id,
|
|
676
|
-
payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
|
|
677
|
-
});
|
|
678
|
-
});
|
|
795
|
+
result = finalizeArchive(db, prepared.intent, { rootDir });
|
|
679
796
|
} catch (error) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
797
|
+
try { archiveJournal.rollbackArchiveIntent(rootDir, prepared.intent); }
|
|
798
|
+
catch (rollbackError) {
|
|
799
|
+
throw new ChangeWorkflowError('ARCHIVE_RECOVERY_REQUIRED', error.message, {
|
|
800
|
+
cause: error.code || error.message, rollback: rollbackError.code || rollbackError.message,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
683
803
|
throw error;
|
|
684
804
|
}
|
|
685
|
-
|
|
805
|
+
try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
|
|
806
|
+
catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
|
|
807
|
+
return result;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
|
|
811
|
+
const records = archiveJournal.listArchiveIntents(rootDir);
|
|
812
|
+
const result = { found: records.length, resumed: 0, rolled_back: 0, cleaned: 0, failed: 0, items: [] };
|
|
813
|
+
for (const record of records) {
|
|
814
|
+
if (record.error) {
|
|
815
|
+
result.failed += 1;
|
|
816
|
+
result.items.push({ file: record.file, status: 'failed', error: record.error.code || record.error.message });
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const change = readChange(db, record.intent.change_id);
|
|
820
|
+
try {
|
|
821
|
+
if (change?.status === 'archived' && change.artifact_root === record.intent.destination) {
|
|
822
|
+
archiveJournal.completeArchiveIntent(rootDir, record.intent);
|
|
823
|
+
result.cleaned += 1;
|
|
824
|
+
result.items.push({ change_id: change.id, status: 'cleaned' });
|
|
825
|
+
} else if (change?.status === 'ready' && fs.existsSync(record.destination)
|
|
826
|
+
&& !fs.existsSync(record.source)) {
|
|
827
|
+
finalizeArchive(db, record.intent, { rootDir });
|
|
828
|
+
archiveJournal.completeArchiveIntent(rootDir, record.intent);
|
|
829
|
+
result.resumed += 1;
|
|
830
|
+
result.items.push({ change_id: change.id, status: 'resumed' });
|
|
831
|
+
} else {
|
|
832
|
+
archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
|
|
833
|
+
result.rolled_back += 1;
|
|
834
|
+
result.items.push({ change_id: record.intent.change_id, status: 'rolled_back' });
|
|
835
|
+
}
|
|
836
|
+
} catch (error) {
|
|
837
|
+
try {
|
|
838
|
+
archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
|
|
839
|
+
result.rolled_back += 1;
|
|
840
|
+
result.items.push({
|
|
841
|
+
change_id: record.intent.change_id, status: 'rolled_back',
|
|
842
|
+
error: error.code || error.message,
|
|
843
|
+
});
|
|
844
|
+
} catch (rollbackError) {
|
|
845
|
+
result.failed += 1;
|
|
846
|
+
result.items.push({
|
|
847
|
+
change_id: record.intent.change_id, status: 'failed',
|
|
848
|
+
error: `${error.code || error.message}; ${rollbackError.code || rollbackError.message}`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return result;
|
|
686
854
|
}
|
|
687
855
|
|
|
688
856
|
module.exports = {
|
|
@@ -700,4 +868,7 @@ module.exports = {
|
|
|
700
868
|
archiveChange,
|
|
701
869
|
normalizeDocsImpact,
|
|
702
870
|
normalizeProviderRefs,
|
|
871
|
+
normalizeBaselineBypass,
|
|
872
|
+
assertTaskCreationAllowed,
|
|
873
|
+
recoverInterruptedArchives,
|
|
703
874
|
};
|