ultra-builder-pro-cli 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/discovery.md +10 -0
- package/CHANGELOG.md +95 -1
- package/README.md +31 -16
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +21 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +10 -6
- package/docs/ARCHITECTURE.md +40 -17
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +2 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -299
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +14 -37
- package/mcp-server/lib/task-expander.test.cjs +80 -164
- package/mcp-server/server.cjs +34 -37
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +391 -17
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +20 -7
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +133 -19
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
|
@@ -10,6 +10,7 @@ const contextSpine = require('./context-spine.cjs');
|
|
|
10
10
|
const specLearning = require('./spec-learning.cjs');
|
|
11
11
|
const providerRefs = require('./provider-refs.cjs');
|
|
12
12
|
const baselines = require('./baseline-workflow.cjs');
|
|
13
|
+
const archiveJournal = require('./archive-journal.cjs');
|
|
13
14
|
|
|
14
15
|
const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
15
16
|
const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
|
|
@@ -60,9 +61,12 @@ function rowToChange(row) {
|
|
|
60
61
|
...row,
|
|
61
62
|
docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
|
|
62
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,
|
|
63
66
|
};
|
|
64
67
|
delete change.docs_impact_json;
|
|
65
68
|
delete change.provider_refs_json;
|
|
69
|
+
delete change.baseline_bypass_json;
|
|
66
70
|
return change;
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -84,6 +88,29 @@ function normalizeProviderRefs(value) {
|
|
|
84
88
|
return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
|
|
85
89
|
}
|
|
86
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
|
+
);
|
|
97
|
+
}
|
|
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
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
function gitHead(rootDir) {
|
|
88
115
|
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
89
116
|
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -131,6 +158,14 @@ function writeIntent(file, change) {
|
|
|
131
158
|
'',
|
|
132
159
|
];
|
|
133
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
|
+
}
|
|
134
169
|
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
135
170
|
}
|
|
136
171
|
|
|
@@ -219,6 +254,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
219
254
|
change: {
|
|
220
255
|
id: change.id, title: change.title, kind: change.kind, status: change.status,
|
|
221
256
|
intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
|
|
257
|
+
baseline_bypass: change.baseline_bypass,
|
|
222
258
|
},
|
|
223
259
|
git: { head },
|
|
224
260
|
tasks,
|
|
@@ -299,7 +335,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
299
335
|
if (!REQUIRED_EVIDENCE[input.kind]) {
|
|
300
336
|
throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
|
|
301
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
|
+
}
|
|
302
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
|
+
}
|
|
303
360
|
const docsImpact = normalizeDocsImpact(input.docs_impact);
|
|
304
361
|
const providers = normalizeProviderRefs(input.provider_refs);
|
|
305
362
|
const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
|
|
@@ -310,7 +367,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
310
367
|
const ts = nowIso();
|
|
311
368
|
const row = {
|
|
312
369
|
id: input.id, title, kind: input.kind, status: 'active',
|
|
313
|
-
intent, docs_impact: docsImpact, provider_refs: providers,
|
|
370
|
+
intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
|
|
314
371
|
base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
|
|
315
372
|
created_at: ts, updated_at: ts,
|
|
316
373
|
};
|
|
@@ -319,11 +376,13 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
319
376
|
db.prepare(
|
|
320
377
|
`INSERT INTO changes
|
|
321
378
|
(id, title, kind, status, intent, docs_impact_json, provider_refs_json,
|
|
322
|
-
base_commit, artifact_root, created_at, updated_at)
|
|
323
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
379
|
+
baseline_bypass_json, base_commit, artifact_root, created_at, updated_at)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
324
381
|
).run(
|
|
325
382
|
row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
|
|
326
|
-
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,
|
|
327
386
|
);
|
|
328
387
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
329
388
|
const intentPath = path.join(artifactDir, 'intent.md');
|
|
@@ -359,6 +418,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
359
418
|
}
|
|
360
419
|
}
|
|
361
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
|
+
|
|
362
443
|
function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
|
|
363
444
|
const current = readChange(db, id);
|
|
364
445
|
if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
|
|
@@ -481,11 +562,15 @@ function evidenceBlockers(change, evidence, tasks) {
|
|
|
481
562
|
return blockers;
|
|
482
563
|
}
|
|
483
564
|
|
|
484
|
-
function baselineConvergenceBlockers(db) {
|
|
565
|
+
function baselineConvergenceBlockers(db, change, rootDir) {
|
|
485
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
|
+
}
|
|
486
571
|
if (!baseline) return ['BASELINE_MISSING'];
|
|
487
|
-
if (baseline.
|
|
488
|
-
return [];
|
|
572
|
+
if (baseline.mode === 'migrated') return ['BASELINE_MIGRATION_REVIEW_REQUIRED'];
|
|
573
|
+
return [`BASELINE_NOT_READY:${baseline.status}`];
|
|
489
574
|
}
|
|
490
575
|
|
|
491
576
|
function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
@@ -496,7 +581,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
496
581
|
}
|
|
497
582
|
const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
|
|
498
583
|
const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
|
|
499
|
-
for (const blocker of baselineConvergenceBlockers(db)) blockers.add(blocker);
|
|
584
|
+
for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
|
|
500
585
|
if (tasks.length === 0) blockers.add('NO_TASKS');
|
|
501
586
|
if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
|
|
502
587
|
if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
|
|
@@ -593,12 +678,98 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
593
678
|
return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
|
|
594
679
|
}
|
|
595
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
|
+
|
|
596
770
|
function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
597
771
|
const change = readChange(db, input.id);
|
|
598
772
|
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
|
|
599
|
-
if (change.status !== 'ready') {
|
|
600
|
-
throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
|
|
601
|
-
}
|
|
602
773
|
if (!input.summary || String(input.summary).trim().length < 3) {
|
|
603
774
|
throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
|
|
604
775
|
}
|
|
@@ -612,74 +783,74 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
612
783
|
throw new ChangeWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${file}`);
|
|
613
784
|
}
|
|
614
785
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath) : null;
|
|
624
|
-
fs.writeFileSync(summaryPath, [
|
|
625
|
-
`# Archived change: ${change.title}`, '', String(input.summary).trim(), '',
|
|
626
|
-
'## Baseline reconciliation', '',
|
|
627
|
-
...(updates.length > 0 ? updates.map((file) => `- ${file}`) : [noChangeReason]), '',
|
|
628
|
-
].join('\n'));
|
|
629
|
-
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;
|
|
630
794
|
try {
|
|
631
|
-
|
|
632
|
-
const relative = path.relative(rootDir, destination);
|
|
633
|
-
db.prepare(
|
|
634
|
-
"UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
|
|
635
|
-
).run(relative, nowIso(), nowIso(), change.id);
|
|
636
|
-
const prefix = `${change.artifact_root}${path.sep}`;
|
|
637
|
-
const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?')
|
|
638
|
-
.all(change.id);
|
|
639
|
-
for (const artifact of artifactRows) {
|
|
640
|
-
if (artifact.path !== change.artifact_root && !artifact.path.startsWith(prefix)) continue;
|
|
641
|
-
const suffix = path.relative(change.artifact_root, artifact.path);
|
|
642
|
-
const archivedPath = suffix ? path.join(relative, suffix) : relative;
|
|
643
|
-
db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
|
|
644
|
-
.run(archivedPath, nowIso(), artifact.id);
|
|
645
|
-
}
|
|
646
|
-
const archivedSummary = path.join(destination, 'archive-summary.md');
|
|
647
|
-
upsertArtifact(db, {
|
|
648
|
-
change_id: change.id,
|
|
649
|
-
kind: 'archive_summary',
|
|
650
|
-
artifactPath: path.relative(rootDir, archivedSummary),
|
|
651
|
-
contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
|
|
652
|
-
metadata: {
|
|
653
|
-
baseline_updates: updates,
|
|
654
|
-
no_baseline_change_reason: noChangeReason || null,
|
|
655
|
-
},
|
|
656
|
-
});
|
|
657
|
-
db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
|
|
658
|
-
.run(nowIso(), change.id);
|
|
659
|
-
baselines.reconcileBaseline(db, {
|
|
660
|
-
baseline_updates: updates,
|
|
661
|
-
change_id: change.id,
|
|
662
|
-
}, { rootDir });
|
|
663
|
-
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
664
|
-
if (baselineHealth.blockers.length > 0) {
|
|
665
|
-
throw new ChangeWorkflowError(
|
|
666
|
-
'BASELINE_RECONCILIATION_INCOMPLETE',
|
|
667
|
-
`baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
|
|
668
|
-
{ blockers: baselineHealth.blockers },
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
ops.appendEventInTx(db, {
|
|
672
|
-
type: 'change_archived', change_id: change.id,
|
|
673
|
-
payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
|
|
674
|
-
});
|
|
675
|
-
});
|
|
795
|
+
result = finalizeArchive(db, prepared.intent, { rootDir });
|
|
676
796
|
} catch (error) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
}
|
|
680
803
|
throw error;
|
|
681
804
|
}
|
|
682
|
-
|
|
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;
|
|
683
854
|
}
|
|
684
855
|
|
|
685
856
|
module.exports = {
|
|
@@ -697,4 +868,7 @@ module.exports = {
|
|
|
697
868
|
archiveChange,
|
|
698
869
|
normalizeDocsImpact,
|
|
699
870
|
normalizeProviderRefs,
|
|
871
|
+
normalizeBaselineBypass,
|
|
872
|
+
assertTaskCreationAllowed,
|
|
873
|
+
recoverInterruptedArchives,
|
|
700
874
|
};
|