ultra-builder-pro-cli 0.20.0 → 0.22.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 +26 -5
- package/.ultra-template/specs/discovery.md +1 -1
- package/.ultra-template/tasks/tasks.json +10 -3
- package/.ultra-template/templates/task-context.md +19 -0
- package/CHANGELOG.md +106 -0
- package/README.md +215 -41
- package/adapters/_shared/codex-assets.cjs +69 -11
- package/adapters/_shared/interaction-contract.cjs +36 -3
- package/adapters/_shared/runtime-assets.cjs +41 -1
- package/adapters/claude.js +7 -5
- package/adapters/codex.js +1 -1
- package/adapters/kimi.js +2 -8
- package/adapters/opencode.js +43 -30
- package/bin/orchestrator.js +64 -24
- package/docs/AGENT-CONTEXT.md +89 -49
- package/docs/ARCHITECTURE.md +112 -45
- package/docs/ARTIFACT-AUTHORITY.md +274 -0
- package/docs/COMMIT-HASH-BACKFILL.md +33 -90
- package/docs/DECISIONS.md +14 -6
- package/docs/LEGACY-CLI-CROSSWALK.md +16 -6
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +6 -4
- package/docs/ROADMAP.md +20 -6
- package/docs/RUNTIME-COMPAT-MATRIX.md +29 -16
- package/docs/STATE-DB-ACCESS-POLICY.md +43 -13
- package/docs/WORKFLOW-LIFECYCLE.md +105 -50
- package/hooks/README.md +10 -8
- package/hooks/active_task_context.py +105 -18
- package/hooks/context_spine.py +16 -4
- package/hooks/health_check.py +5 -4
- package/hooks/pre_stop_check.py +5 -2
- package/hooks/runtime_paths.py +396 -0
- package/hooks/subagent_tracker.py +5 -2
- package/hooks/workflow_checkpoint.py +16 -3
- package/hooks/workflow_context.py +11 -3
- package/hooks/workflow_resume.py +7 -4
- package/mcp-server/breadcrumb.cjs +20 -2
- package/mcp-server/lib/archive-journal.cjs +1026 -111
- package/mcp-server/lib/archive-mutation-worker.py +365 -0
- package/mcp-server/lib/artifact-registry.cjs +1127 -0
- package/mcp-server/lib/baseline-workflow.cjs +69 -28
- package/mcp-server/lib/change-packet.cjs +818 -0
- package/mcp-server/lib/change-workflow.cjs +932 -176
- package/mcp-server/lib/context-paths.cjs +142 -0
- package/mcp-server/lib/context-spine.cjs +511 -45
- package/mcp-server/lib/decision-dialogue.cjs +345 -3
- package/mcp-server/lib/delivery-transaction.cjs +471 -0
- package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
- package/mcp-server/lib/doctor.cjs +401 -11
- package/mcp-server/lib/git-bootstrap.cjs +458 -47
- package/mcp-server/lib/init-project.cjs +240 -95
- package/mcp-server/lib/plan-publication-worker.cjs +830 -0
- package/mcp-server/lib/plan-store.cjs +700 -5
- package/mcp-server/lib/project-breadcrumb.cjs +23 -4
- package/mcp-server/lib/projector.cjs +211 -42
- package/mcp-server/lib/runtime-paths.cjs +1687 -0
- package/mcp-server/lib/safe-project-file.cjs +385 -0
- package/mcp-server/lib/spec-learning.cjs +28 -12
- package/mcp-server/lib/state-authority.cjs +19 -3
- package/mcp-server/lib/state-db.cjs +263 -9
- package/mcp-server/lib/state-ops.cjs +254 -50
- package/mcp-server/lib/task-ledger.cjs +1342 -0
- package/mcp-server/lib/telemetry.cjs +12 -3
- package/mcp-server/lib/workflow-state.cjs +610 -58
- package/mcp-server/server.cjs +423 -42
- package/orchestrator/daemon.cjs +2 -10
- package/orchestrator/parallel-orchestrator.cjs +314 -50
- package/orchestrator/planner/plan-builder.cjs +1 -1
- package/orchestrator/recovery.cjs +125 -7
- package/orchestrator/session-close-journal-worker.cjs +5 -0
- package/orchestrator/session-close-journal.cjs +1014 -0
- package/orchestrator/session-runner.cjs +679 -69
- package/orchestrator/worktree-manager.cjs +186 -35
- package/package.json +2 -3
- package/skills/cc-collab/SKILL.md +4 -1
- package/skills/codex-collab/SKILL.md +5 -2
- package/skills/ultra-change/SKILL.md +34 -8
- package/skills/ultra-deliver/SKILL.md +26 -11
- package/skills/ultra-dev/SKILL.md +52 -10
- package/skills/ultra-doctor/SKILL.md +16 -3
- package/skills/ultra-init/SKILL.md +22 -11
- package/skills/ultra-plan/SKILL.md +58 -18
- package/skills/ultra-research/SKILL.md +37 -35
- package/skills/ultra-review/SKILL.md +14 -8
- package/skills/ultra-status/SKILL.md +18 -9
- package/skills/ultra-test/SKILL.md +11 -5
- package/skills/ultra-think/SKILL.md +6 -2
- package/skills/ultra-think/references/decision-dialogue.md +17 -6
- package/skills/ultra-verify/SKILL.md +4 -1
- package/spec/cli-protocol.md +21 -7
- package/spec/mcp-tools.yaml +629 -44
- package/spec/schemas/change-delta.v1.schema.json +153 -0
- package/spec/schemas/context-file.v4.5.schema.json +1 -1
- package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +51 -14
- package/spec/schemas/task-ledger.v1.schema.json +217 -0
- package/spec/schemas/tasks.v4.5.schema.json +1 -1
- package/ultra-tools/cli.cjs +3 -2
- package/ultra-tools/commands/db.cjs +76 -25
- package/ultra-tools/commands/migrate.cjs +1000 -82
- package/ultra-tools/commands/session.cjs +34 -8
- package/ultra-tools/commands/status.cjs +32 -6
- package/ultra-tools/commands/system.cjs +129 -22
- package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
- package/commands/learn.md +0 -10
- package/orchestrator/skill-miner.cjs +0 -133
- package/output-styles/coding-vibes.md +0 -67
- package/output-styles/structural-thinking.md +0 -61
- package/skills/learn/SKILL.md +0 -58
|
@@ -7,11 +7,19 @@ const { spawnSync } = require('node:child_process');
|
|
|
7
7
|
const Ajv = require('ajv/dist/2020');
|
|
8
8
|
|
|
9
9
|
const ops = require('./state-ops.cjs');
|
|
10
|
+
const artifacts = require('./artifact-registry.cjs');
|
|
10
11
|
const contextSpine = require('./context-spine.cjs');
|
|
11
12
|
const specLearning = require('./spec-learning.cjs');
|
|
12
13
|
const providerRefs = require('./provider-refs.cjs');
|
|
13
14
|
const baselines = require('./baseline-workflow.cjs');
|
|
14
15
|
const archiveJournal = require('./archive-journal.cjs');
|
|
16
|
+
const changePacket = require('./change-packet.cjs');
|
|
17
|
+
const deliveryTransaction = require('./delivery-transaction.cjs');
|
|
18
|
+
const {
|
|
19
|
+
openStableProjectRead,
|
|
20
|
+
readStableProjectFile,
|
|
21
|
+
walkStableProjectTree,
|
|
22
|
+
} = require('./safe-project-file.cjs');
|
|
15
23
|
const workflows = require('./workflow-state.cjs');
|
|
16
24
|
const decisions = require('./decision-dialogue.cjs');
|
|
17
25
|
const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
|
|
@@ -288,14 +296,72 @@ function safeRelativePath(rootDir, candidate) {
|
|
|
288
296
|
return target;
|
|
289
297
|
}
|
|
290
298
|
|
|
299
|
+
function rebindRegistryPath(candidate, source, destination) {
|
|
300
|
+
const current = artifacts.normalizeRelativePath(candidate);
|
|
301
|
+
const from = artifacts.normalizeRelativePath(source);
|
|
302
|
+
const to = artifacts.normalizeRelativePath(destination);
|
|
303
|
+
if (current !== from && !current.startsWith(`${from}/`)) return null;
|
|
304
|
+
const suffix = current === from ? '' : path.posix.relative(from, current);
|
|
305
|
+
return suffix ? path.posix.join(to, suffix) : to;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function assertReconciliationAuthority(db, change, relative, digest) {
|
|
309
|
+
const rows = db.prepare(
|
|
310
|
+
"SELECT * FROM artifacts WHERE status <> 'archived' ORDER BY id",
|
|
311
|
+
).all().filter((item) => {
|
|
312
|
+
try { return artifacts.normalizeRelativePath(item.path) === relative; }
|
|
313
|
+
catch { return false; }
|
|
314
|
+
});
|
|
315
|
+
const row = rows.length === 1 ? rows[0] : null;
|
|
316
|
+
const authorityDigest = row?.digest || row?.content_hash || null;
|
|
317
|
+
if (!row || row.managed !== 1 || row.owner_type !== 'change'
|
|
318
|
+
|| row.owner_id !== change.id || row.change_id !== change.id
|
|
319
|
+
|| row.kind !== 'baseline_reconciliation' || row.status !== 'current'
|
|
320
|
+
|| authorityDigest !== digest) {
|
|
321
|
+
throw new ChangeWorkflowError(
|
|
322
|
+
'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
|
|
323
|
+
'reconciliation manifest must match one current managed Change artifact authority',
|
|
324
|
+
{
|
|
325
|
+
path: relative,
|
|
326
|
+
artifact_ids: rows.map((item) => item.id),
|
|
327
|
+
expected_owner: `change:${change.id}`,
|
|
328
|
+
expected_kind: 'baseline_reconciliation',
|
|
329
|
+
expected_digest: digest,
|
|
330
|
+
actual_digest: authorityDigest,
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return row;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function openReconciliationReader(rootDir, relative) {
|
|
338
|
+
try {
|
|
339
|
+
return openStableProjectRead(rootDir, relative);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error?.code === 'PROJECT_FILE_MISSING') return null;
|
|
342
|
+
throw new ChangeWorkflowError(
|
|
343
|
+
'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
|
|
344
|
+
`reconciliation manifest cannot be read safely: ${relative}`,
|
|
345
|
+
{ cause: error.code || error.message },
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
291
350
|
function readReconciliationManifest(db, change, input, rootDir) {
|
|
292
|
-
const
|
|
351
|
+
const requested = typeof input.reconciliation_path === 'string'
|
|
293
352
|
? input.reconciliation_path.trim() : '';
|
|
294
|
-
if (!
|
|
353
|
+
if (!requested) {
|
|
295
354
|
throw new ChangeWorkflowError(
|
|
296
355
|
'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation_path is required',
|
|
297
356
|
);
|
|
298
357
|
}
|
|
358
|
+
let relative;
|
|
359
|
+
try { relative = artifacts.normalizeRelativePath(requested); }
|
|
360
|
+
catch (error) {
|
|
361
|
+
throw new ChangeWorkflowError(
|
|
362
|
+
'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', error.message,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
299
365
|
let file = safeRelativePath(rootDir, relative);
|
|
300
366
|
const artifactRoot = path.resolve(rootDir, change.artifact_root);
|
|
301
367
|
if (!file.startsWith(`${artifactRoot}${path.sep}`)) {
|
|
@@ -304,26 +370,75 @@ function readReconciliationManifest(db, change, input, rootDir) {
|
|
|
304
370
|
'reconciliation manifest must be a file inside the active change artifact root',
|
|
305
371
|
);
|
|
306
372
|
}
|
|
373
|
+
let reader = openReconciliationReader(rootDir, relative);
|
|
307
374
|
let manifest;
|
|
308
|
-
|
|
375
|
+
let digest;
|
|
376
|
+
let expectedDigest = null;
|
|
377
|
+
let authorityDigest = null;
|
|
378
|
+
let journalIntent = null;
|
|
379
|
+
if (!reader) {
|
|
309
380
|
const pending = archiveJournal.listArchiveIntents(rootDir).find(
|
|
310
381
|
(item) => !item.error && item.intent.change_id === change.id
|
|
311
382
|
&& item.intent.reconciliation_path === relative,
|
|
312
383
|
);
|
|
313
384
|
if (pending) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
385
|
+
journalIntent = pending.intent;
|
|
386
|
+
const suffix = path.posix.relative(pending.intent.source, relative);
|
|
387
|
+
const archivedRelative = path.posix.join(pending.intent.destination, suffix);
|
|
388
|
+
file = safeRelativePath(rootDir, archivedRelative);
|
|
389
|
+
reader = openReconciliationReader(rootDir, archivedRelative);
|
|
390
|
+
expectedDigest = pending.intent.reconciliation_digest;
|
|
391
|
+
authorityDigest = pending.intent.reconciliation_digest;
|
|
392
|
+
const rebind = archiveJournal.readArchiveRebind(rootDir, pending.intent);
|
|
393
|
+
const rebound = rebind?.entries.find(
|
|
394
|
+
(entry) => entry.relative_path
|
|
395
|
+
=== path.posix.relative(pending.intent.destination, archivedRelative),
|
|
396
|
+
);
|
|
397
|
+
if (rebound) {
|
|
398
|
+
if (rebound.before_digest !== pending.intent.reconciliation_digest) {
|
|
399
|
+
throw new ChangeWorkflowError(
|
|
400
|
+
'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
|
|
401
|
+
'archive rebind journal does not begin from the prepared reconciliation authority',
|
|
402
|
+
{
|
|
403
|
+
expected_digest: pending.intent.reconciliation_digest,
|
|
404
|
+
actual_digest: rebound.before_digest,
|
|
405
|
+
},
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
expectedDigest = rebound.after_digest;
|
|
409
|
+
authorityDigest = rebound.before_digest;
|
|
410
|
+
}
|
|
317
411
|
}
|
|
318
412
|
}
|
|
319
|
-
if (!
|
|
413
|
+
if (!reader) {
|
|
320
414
|
throw new ChangeWorkflowError(
|
|
321
415
|
'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation manifest file is missing',
|
|
322
416
|
);
|
|
323
417
|
}
|
|
324
|
-
try {
|
|
325
|
-
|
|
418
|
+
try {
|
|
419
|
+
digest = reader.digest;
|
|
420
|
+
if (expectedDigest && digest !== expectedDigest) {
|
|
421
|
+
throw new ChangeWorkflowError(
|
|
422
|
+
'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
|
|
423
|
+
'archived reconciliation bytes do not match the prepared journal digest',
|
|
424
|
+
{ expected_digest: expectedDigest, actual_digest: digest },
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
assertReconciliationAuthority(db, change, relative, authorityDigest || digest);
|
|
428
|
+
manifest = JSON.parse(reader.bytes.toString('utf8'));
|
|
429
|
+
reader.verify();
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (error instanceof ChangeWorkflowError) throw error;
|
|
432
|
+
if (error?.code === 'PROJECT_FILE_CHANGED' || error?.code === 'PROJECT_FILE_UNSAFE') {
|
|
433
|
+
throw new ChangeWorkflowError(
|
|
434
|
+
'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
|
|
435
|
+
`reconciliation manifest changed while it was read: ${relative}`,
|
|
436
|
+
{ cause: error.code },
|
|
437
|
+
);
|
|
438
|
+
}
|
|
326
439
|
throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
|
|
440
|
+
} finally {
|
|
441
|
+
reader.close();
|
|
327
442
|
}
|
|
328
443
|
if (!validateReconciliationSchema(manifest)) {
|
|
329
444
|
throw new ChangeWorkflowError(
|
|
@@ -413,10 +528,10 @@ function readReconciliationManifest(db, change, input, rootDir) {
|
|
|
413
528
|
'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'resolved gap or unknown does not exist in baseline authority',
|
|
414
529
|
);
|
|
415
530
|
}
|
|
416
|
-
const serialized = fs.readFileSync(file);
|
|
417
531
|
return {
|
|
418
532
|
manifest, relative, file,
|
|
419
|
-
digest
|
|
533
|
+
digest,
|
|
534
|
+
journal_intent: journalIntent,
|
|
420
535
|
};
|
|
421
536
|
}
|
|
422
537
|
|
|
@@ -520,18 +635,50 @@ function inspectIncidentDiagnosis(change, artifactDir, rootDir) {
|
|
|
520
635
|
};
|
|
521
636
|
}
|
|
522
637
|
|
|
523
|
-
function
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
638
|
+
function latestWorkflowConsumer(db, changeId) {
|
|
639
|
+
const workflow = db.prepare(
|
|
640
|
+
`SELECT id FROM workflow_runs WHERE change_id = ?
|
|
641
|
+
ORDER BY started_at DESC, rowid DESC LIMIT 1`,
|
|
642
|
+
).get(changeId);
|
|
643
|
+
if (!workflow) {
|
|
644
|
+
throw new ChangeWorkflowError(
|
|
645
|
+
'ARTIFACT_CONSUMER_MISSING',
|
|
646
|
+
`change ${changeId} has no workflow consumer for its durable artifacts`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return { type: 'workflow', id: workflow.id, relation: 'consumed_by' };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function upsertArtifact(
|
|
653
|
+
db,
|
|
654
|
+
{
|
|
655
|
+
change_id, task_id = null, kind, artifactPath, contentHash, metadata,
|
|
656
|
+
workflow_id = null, terminal = false, rootDir = process.cwd(),
|
|
657
|
+
},
|
|
658
|
+
) {
|
|
659
|
+
const ownerType = task_id ? 'task' : 'change';
|
|
660
|
+
const ownerId = task_id || change_id;
|
|
661
|
+
const consumer = terminal
|
|
662
|
+
? null
|
|
663
|
+
: task_id
|
|
664
|
+
? { type: 'task', id: task_id, relation: 'consumed_by' }
|
|
665
|
+
: workflow_id
|
|
666
|
+
? { type: 'workflow', id: workflow_id, relation: 'consumed_by' }
|
|
667
|
+
: latestWorkflowConsumer(db, change_id);
|
|
668
|
+
return artifacts.recordArtifactInTx(db, {
|
|
669
|
+
owner_type: ownerType,
|
|
670
|
+
owner_id: ownerId,
|
|
671
|
+
kind,
|
|
672
|
+
path: artifactPath,
|
|
673
|
+
content_digest: contentHash || undefined,
|
|
674
|
+
source_refs: [{ type: 'change', id: change_id, relation: 'produced_for' }],
|
|
675
|
+
consumer_refs: consumer ? [consumer] : [],
|
|
676
|
+
provenance: {
|
|
677
|
+
writer: 'change-workflow',
|
|
678
|
+
workflow_run_id: consumer?.type === 'workflow' ? consumer.id : null,
|
|
679
|
+
},
|
|
680
|
+
metadata: { ...(metadata || {}), ...(terminal ? { terminal_role: true } : {}) },
|
|
681
|
+
}, { rootDir });
|
|
535
682
|
}
|
|
536
683
|
|
|
537
684
|
function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
@@ -540,9 +687,13 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
540
687
|
if (['archived', 'cancelled'].includes(change.status)) {
|
|
541
688
|
throw new ChangeWorkflowError('CHANGE_TERMINAL', `change ${input.id} is ${change.status}`);
|
|
542
689
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
690
|
+
if (Object.hasOwn(input, 'provider_refs')) {
|
|
691
|
+
throw new ChangeWorkflowError(
|
|
692
|
+
'CONTEXT_PROVIDER_REFS_MUTATION_UNSUPPORTED',
|
|
693
|
+
'provider refs are Change authority; update them through change.update before compiling context',
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const providers = change.provider_refs;
|
|
546
697
|
const tasks = db.prepare(
|
|
547
698
|
'SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
|
|
548
699
|
).all(change.id).map((task) => ops.readTask(db, task.id));
|
|
@@ -568,7 +719,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
568
719
|
worktree_state: spine.resume.worktree_state,
|
|
569
720
|
worktree_digest: spine.resume.worktree_digest,
|
|
570
721
|
},
|
|
571
|
-
tasks,
|
|
722
|
+
tasks: spine.task_scope,
|
|
572
723
|
selected_task: spine.selected_task,
|
|
573
724
|
specs,
|
|
574
725
|
role: spine.role,
|
|
@@ -577,6 +728,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
577
728
|
readiness: spine.readiness,
|
|
578
729
|
context: spine.context,
|
|
579
730
|
execution_contract: spine.execution_contract,
|
|
731
|
+
task_context_contract: spine.task_context_contract,
|
|
580
732
|
baseline: spine.baseline,
|
|
581
733
|
resume: spine.resume,
|
|
582
734
|
providers,
|
|
@@ -610,6 +762,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
610
762
|
upsertArtifact(db, {
|
|
611
763
|
change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
|
|
612
764
|
artifactPath: relative, contentHash: hash,
|
|
765
|
+
rootDir,
|
|
613
766
|
metadata: {
|
|
614
767
|
git_head: spine.resume.git_head,
|
|
615
768
|
worktree_digest: spine.resume.worktree_digest,
|
|
@@ -624,8 +777,6 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
624
777
|
(change_id, task_id, source_ref, target_ref, relation) VALUES (?, ?, ?, ?, ?)`,
|
|
625
778
|
).run(change.id, task.id, task.trace_to, `task:${task.id}`, 'specified_by');
|
|
626
779
|
}
|
|
627
|
-
db.prepare('UPDATE changes SET provider_refs_json = ?, updated_at = ? WHERE id = ?')
|
|
628
|
-
.run(JSON.stringify(providers), nowIso(), change.id);
|
|
629
780
|
ops.appendEventInTx(db, {
|
|
630
781
|
type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
|
|
631
782
|
payload: {
|
|
@@ -758,20 +909,9 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
758
909
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
759
910
|
const intentPath = path.join(artifactDir, 'intent.md');
|
|
760
911
|
writeIntent(intentPath, row);
|
|
761
|
-
upsertArtifact(db, {
|
|
762
|
-
change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
|
|
763
|
-
contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
|
|
764
|
-
});
|
|
765
912
|
if (row.kind === 'incident') {
|
|
766
913
|
const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
|
|
767
914
|
writeIncidentDiagnosis(diagnosisPath, row);
|
|
768
|
-
upsertArtifact(db, {
|
|
769
|
-
change_id: row.id,
|
|
770
|
-
kind: 'diagnosis',
|
|
771
|
-
artifactPath: path.relative(rootDir, diagnosisPath),
|
|
772
|
-
contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
|
|
773
|
-
metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
|
|
774
|
-
});
|
|
775
915
|
}
|
|
776
916
|
ops.appendEventInTx(db, {
|
|
777
917
|
type: 'change_created', change_id: row.id,
|
|
@@ -785,6 +925,24 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
785
925
|
change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
|
|
786
926
|
metadata: { change_kind: row.kind, base_commit: row.base_commit },
|
|
787
927
|
}, { rootDir });
|
|
928
|
+
upsertArtifact(db, {
|
|
929
|
+
change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
|
|
930
|
+
contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
|
|
931
|
+
workflow_id: workflow.id,
|
|
932
|
+
rootDir,
|
|
933
|
+
});
|
|
934
|
+
if (row.kind === 'incident') {
|
|
935
|
+
const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
|
|
936
|
+
upsertArtifact(db, {
|
|
937
|
+
change_id: row.id,
|
|
938
|
+
kind: 'diagnosis',
|
|
939
|
+
artifactPath: path.relative(rootDir, diagnosisPath),
|
|
940
|
+
contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
|
|
941
|
+
metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
|
|
942
|
+
workflow_id: workflow.id,
|
|
943
|
+
rootDir,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
788
946
|
workflow = workflows.recordWorkflowStep(db, {
|
|
789
947
|
id: workflow.id, step_id: 'bind-baseline', status: 'completed',
|
|
790
948
|
evidence: [{
|
|
@@ -906,44 +1064,45 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
|
|
|
906
1064
|
sets.push('updated_at = ?'); values.push(nowIso(), id);
|
|
907
1065
|
db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
908
1066
|
const updated = readChange(db, id);
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
const tasks = db.prepare(
|
|
912
|
-
'SELECT id, stale FROM tasks WHERE change_id = ? ORDER BY created_at ASC, id ASC',
|
|
913
|
-
).all(id);
|
|
914
|
-
const markStale = db.prepare(
|
|
915
|
-
'UPDATE tasks SET stale = 1, updated_at = ? WHERE id = ?',
|
|
916
|
-
);
|
|
917
|
-
const invalidatedAt = nowIso();
|
|
918
|
-
for (const task of tasks) {
|
|
919
|
-
if (task.stale) continue;
|
|
920
|
-
markStale.run(invalidatedAt, task.id);
|
|
921
|
-
invalidatedTasks.push(task.id);
|
|
922
|
-
ops.appendEventInTx(db, {
|
|
923
|
-
type: 'task_stale_marked',
|
|
924
|
-
task_id: task.id,
|
|
925
|
-
change_id: id,
|
|
926
|
-
payload: {
|
|
927
|
-
reason: 'change_semantic_authority_updated',
|
|
928
|
-
fields: Object.keys(patch).filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field)),
|
|
929
|
-
},
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
}
|
|
1067
|
+
const invalidated = [];
|
|
1068
|
+
let intentArtifactId = null;
|
|
933
1069
|
if (syncIntent) {
|
|
934
1070
|
fs.mkdirSync(path.dirname(intentPath), { recursive: true });
|
|
935
1071
|
writeIntent(intentPath, updated);
|
|
936
|
-
upsertArtifact(db, {
|
|
1072
|
+
const recorded = upsertArtifact(db, {
|
|
937
1073
|
change_id: id,
|
|
938
1074
|
kind: 'intent',
|
|
939
1075
|
artifactPath: path.relative(rootDir, intentPath),
|
|
940
1076
|
contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
|
|
1077
|
+
rootDir,
|
|
941
1078
|
});
|
|
1079
|
+
intentArtifactId = recorded.artifact.id;
|
|
1080
|
+
invalidated.push(...recorded.invalidated);
|
|
1081
|
+
}
|
|
1082
|
+
const semanticFields = Object.keys(patch)
|
|
1083
|
+
.filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field));
|
|
1084
|
+
if (semanticFields.length > 0
|
|
1085
|
+
&& contextSpine.changeStateDigest(current) !== contextSpine.changeStateDigest(updated)) {
|
|
1086
|
+
invalidated.push(...artifacts.invalidateConsumersFromEndpointInTx(
|
|
1087
|
+
db,
|
|
1088
|
+
{ type: 'change', id },
|
|
1089
|
+
{
|
|
1090
|
+
reason: 'change_semantic_authority_updated',
|
|
1091
|
+
exclude: intentArtifactId ? [{ type: 'artifact', id: intentArtifactId }] : [],
|
|
1092
|
+
},
|
|
1093
|
+
));
|
|
942
1094
|
}
|
|
1095
|
+
const invalidatedTasks = [...new Set(
|
|
1096
|
+
invalidated.filter((item) => item.type === 'task').map((item) => item.id),
|
|
1097
|
+
)].sort();
|
|
943
1098
|
ops.appendEventInTx(db, {
|
|
944
1099
|
type: 'change_updated',
|
|
945
1100
|
change_id: id,
|
|
946
|
-
payload: {
|
|
1101
|
+
payload: {
|
|
1102
|
+
fields: Object.keys(patch),
|
|
1103
|
+
invalidated_tasks: invalidatedTasks,
|
|
1104
|
+
invalidated_authorities: invalidated,
|
|
1105
|
+
},
|
|
947
1106
|
});
|
|
948
1107
|
return updated;
|
|
949
1108
|
});
|
|
@@ -956,6 +1115,35 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
|
|
|
956
1115
|
}
|
|
957
1116
|
}
|
|
958
1117
|
|
|
1118
|
+
function recordDelta(db, input, { rootDir = process.cwd() } = {}) {
|
|
1119
|
+
const change = readChange(db, input?.id);
|
|
1120
|
+
if (!change) {
|
|
1121
|
+
throw new ChangeWorkflowError(
|
|
1122
|
+
'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return changePacket.recordDelta(db, change, input, { rootDir });
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function recordDocumentationReconciliation(
|
|
1129
|
+
db,
|
|
1130
|
+
input,
|
|
1131
|
+
{ rootDir = process.cwd() } = {},
|
|
1132
|
+
) {
|
|
1133
|
+
const change = readChange(db, input?.id);
|
|
1134
|
+
if (!change) {
|
|
1135
|
+
throw new ChangeWorkflowError(
|
|
1136
|
+
'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
return changePacket.recordDocumentationReconciliation(
|
|
1140
|
+
db,
|
|
1141
|
+
change,
|
|
1142
|
+
input,
|
|
1143
|
+
{ rootDir },
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
959
1147
|
function filesUnder(dir) {
|
|
960
1148
|
if (!fs.existsSync(dir)) return [];
|
|
961
1149
|
const out = [];
|
|
@@ -967,6 +1155,95 @@ function filesUnder(dir) {
|
|
|
967
1155
|
return out;
|
|
968
1156
|
}
|
|
969
1157
|
|
|
1158
|
+
function markdownAnchor(text, fallback) {
|
|
1159
|
+
const heading = String(text).match(/^#{1,6}\s+(.+?)\s*#*\s*$/m)?.[1] || fallback;
|
|
1160
|
+
return String(heading || 'change')
|
|
1161
|
+
.trim()
|
|
1162
|
+
.toLowerCase()
|
|
1163
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
1164
|
+
.replace(/\s+/g, '-')
|
|
1165
|
+
.replace(/-+/g, '-');
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function generatePacketReconciliation(db, change, packet, rootDir) {
|
|
1169
|
+
const baseline = baselines.readBaseline(db);
|
|
1170
|
+
const semanticChanges = packet.delta.value.mutations.map((mutation) => {
|
|
1171
|
+
const overlay = safeRelativePath(rootDir, mutation.overlay_path);
|
|
1172
|
+
const anchor = markdownAnchor(
|
|
1173
|
+
fs.readFileSync(overlay, 'utf8'),
|
|
1174
|
+
path.basename(mutation.target_path, path.extname(mutation.target_path)),
|
|
1175
|
+
);
|
|
1176
|
+
return {
|
|
1177
|
+
id: `delta-${mutation.id}`,
|
|
1178
|
+
action: mutation.action,
|
|
1179
|
+
source_ref: `${mutation.target_path}#${anchor}`,
|
|
1180
|
+
before_digest: mutation.before_digest,
|
|
1181
|
+
after_digest: mutation.after_digest,
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
const verification = [{
|
|
1185
|
+
name: 'typed Change packet read-back',
|
|
1186
|
+
command: 'change.delta',
|
|
1187
|
+
status: 'pass',
|
|
1188
|
+
evidence: `Change delta ${packet.delta.artifact.id} @ ${packet.delta.artifact.digest} and documentation reconciliation ${packet.documentation?.artifact.id || 'none-required'} were read back before Deliver.`,
|
|
1189
|
+
}];
|
|
1190
|
+
const manifest = {
|
|
1191
|
+
$schema: 'ultra-baseline-reconciliation-v1',
|
|
1192
|
+
change_id: change.id,
|
|
1193
|
+
baseline_id: baseline?.id || null,
|
|
1194
|
+
baseline_updates: packet.baseline_updates,
|
|
1195
|
+
semantic_changes: semanticChanges,
|
|
1196
|
+
resolved_gap_ids: [],
|
|
1197
|
+
resolved_unknowns: [],
|
|
1198
|
+
verification,
|
|
1199
|
+
...(semanticChanges.length === 0
|
|
1200
|
+
? {
|
|
1201
|
+
semantic_no_change_reason: packet.delta.value.no_semantic_change_reason
|
|
1202
|
+
|| 'The accepted Change has no baseline specification mutation.',
|
|
1203
|
+
}
|
|
1204
|
+
: {}),
|
|
1205
|
+
};
|
|
1206
|
+
const relative = `${change.artifact_root}/baseline-reconciliation.json`;
|
|
1207
|
+
const file = safeRelativePath(rootDir, relative);
|
|
1208
|
+
const previous = fs.existsSync(file) ? fs.readFileSync(file) : null;
|
|
1209
|
+
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
1210
|
+
fs.writeFileSync(file, serialized);
|
|
1211
|
+
try {
|
|
1212
|
+
const recorded = artifacts.recordArtifact(db, {
|
|
1213
|
+
id: `${change.id}-baseline-reconciliation`,
|
|
1214
|
+
owner_type: 'change',
|
|
1215
|
+
owner_id: change.id,
|
|
1216
|
+
kind: 'baseline_reconciliation',
|
|
1217
|
+
path: relative,
|
|
1218
|
+
content_digest: crypto.createHash('sha256').update(serialized).digest('hex'),
|
|
1219
|
+
source_refs: [
|
|
1220
|
+
{ type: 'artifact', id: packet.delta.artifact.id, relation: 'reconciles' },
|
|
1221
|
+
...(packet.documentation
|
|
1222
|
+
? [{
|
|
1223
|
+
type: 'artifact',
|
|
1224
|
+
id: packet.documentation.artifact.id,
|
|
1225
|
+
relation: 'reconciles_documentation',
|
|
1226
|
+
}]
|
|
1227
|
+
: []),
|
|
1228
|
+
],
|
|
1229
|
+
consumer_refs: [],
|
|
1230
|
+
provenance: { writer: 'change.deliver', generated: true },
|
|
1231
|
+
metadata: { terminal_role: true, schema: manifest.$schema },
|
|
1232
|
+
}, { rootDir }).artifact;
|
|
1233
|
+
return {
|
|
1234
|
+
manifest,
|
|
1235
|
+
relative,
|
|
1236
|
+
file,
|
|
1237
|
+
digest: recorded.digest,
|
|
1238
|
+
artifact: recorded,
|
|
1239
|
+
};
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
if (previous === null) fs.rmSync(file, { force: true });
|
|
1242
|
+
else fs.writeFileSync(file, previous);
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
970
1247
|
function baselineConvergenceBlockers(db, change, rootDir) {
|
|
971
1248
|
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
972
1249
|
return contextSpine.baselineGateForChange(db, change, baselineHealth).blockers;
|
|
@@ -974,14 +1251,21 @@ function baselineConvergenceBlockers(db, change, rootDir) {
|
|
|
974
1251
|
|
|
975
1252
|
function deriveConvergenceEvidence(db, change, tasks, workflowGate, diagnosis, rootDir) {
|
|
976
1253
|
const devRuns = db.prepare(
|
|
977
|
-
`SELECT id, task_id, summary_json FROM workflow_runs
|
|
1254
|
+
`SELECT id, task_id, summary_json, metadata_json FROM workflow_runs
|
|
978
1255
|
WHERE kind = 'dev' AND change_id = ? AND status = 'completed'
|
|
979
1256
|
ORDER BY completed_at ASC, rowid ASC`,
|
|
980
|
-
).all(change.id)
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
.map((
|
|
1257
|
+
).all(change.id)
|
|
1258
|
+
.filter((row) => workflows.isConsumableWorkflowAuthority({
|
|
1259
|
+
metadata: parseJson(row.metadata_json, 'workflow_runs.metadata_json'),
|
|
1260
|
+
}))
|
|
1261
|
+
.map((row) => ({
|
|
1262
|
+
id: row.id, task_id: row.task_id,
|
|
1263
|
+
summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
|
|
1264
|
+
}));
|
|
1265
|
+
const deltaAuthority = changePacket.readDeltaAuthority(db, change.id);
|
|
1266
|
+
const deltaPaths = deltaAuthority
|
|
1267
|
+
? [deltaAuthority.path, deltaAuthority.digest]
|
|
1268
|
+
: [];
|
|
985
1269
|
const appliedLearning = specLearning.listSpecLearning(db, change.id)
|
|
986
1270
|
.filter((candidate) => candidate.status === 'applied')
|
|
987
1271
|
.map((candidate) => candidate.target_ref);
|
|
@@ -1052,20 +1336,26 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1052
1336
|
if (tasks.length === 0) blockers.add('NO_TASKS');
|
|
1053
1337
|
if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
|
|
1054
1338
|
if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
|
|
1055
|
-
for (const task of tasks) {
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1339
|
+
for (const task of tasks.filter((item) => item.status !== 'expanded')) {
|
|
1340
|
+
const validation = contextSpine.validateContextSnapshot(db, {
|
|
1341
|
+
change_id: change.id,
|
|
1342
|
+
task_id: task.id,
|
|
1343
|
+
role: 'implement',
|
|
1344
|
+
gate: 'implementation',
|
|
1345
|
+
}, { rootDir });
|
|
1346
|
+
if (!validation.snapshot) {
|
|
1062
1347
|
blockers.add(`TASK_CONTEXT_MISSING:${task.id}`);
|
|
1063
1348
|
continue;
|
|
1064
1349
|
}
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
if (!
|
|
1350
|
+
for (const blocker of validation.blockers) {
|
|
1351
|
+
blockers.add(`TASK_CONTEXT_INVALID:${task.id}:${blocker}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (!validation.manifest?.execution_contract?.public_seam) {
|
|
1354
|
+
blockers.add(`TASK_SEAM_MISSING:${task.id}`);
|
|
1355
|
+
}
|
|
1356
|
+
if (!validation.manifest?.execution_contract?.verification_command) {
|
|
1357
|
+
blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
|
|
1358
|
+
}
|
|
1069
1359
|
}
|
|
1070
1360
|
|
|
1071
1361
|
for (const candidate of specLearning.listSpecLearning(db, change.id)) {
|
|
@@ -1088,30 +1378,32 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1088
1378
|
const diagnosis = inspectIncidentDiagnosis(change, artifactDir, rootDir);
|
|
1089
1379
|
for (const blocker of diagnosis.blockers) blockers.add(blocker);
|
|
1090
1380
|
if (['standard', 'major'].includes(change.kind)) {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
&& manifest.git.worktree_digest !== checkout.digest) {
|
|
1110
|
-
blockers.add('CONTEXT_WORKTREE_STALE');
|
|
1381
|
+
let delta = null;
|
|
1382
|
+
try {
|
|
1383
|
+
delta = changePacket.loadDelta(db, change.id, { rootDir });
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
blockers.add(error.code || 'CHANGE_DELTA_AUTHORITY_MISSING');
|
|
1386
|
+
}
|
|
1387
|
+
if (delta) {
|
|
1388
|
+
try {
|
|
1389
|
+
const documentation = changePacket.loadDocumentationReconciliation(
|
|
1390
|
+
db, change.id, { rootDir },
|
|
1391
|
+
);
|
|
1392
|
+
if (documentation.value.delta_artifact_id !== delta.artifact.id
|
|
1393
|
+
|| documentation.value.delta_digest !== delta.artifact.digest) {
|
|
1394
|
+
blockers.add('DOCUMENTATION_DELTA_AUTHORITY_INVALID');
|
|
1395
|
+
}
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
blockers.add(error.code || 'DOCUMENTATION_RECONCILIATION_MISSING');
|
|
1398
|
+
}
|
|
1111
1399
|
}
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1400
|
+
const planArtifact = db.prepare(
|
|
1401
|
+
`SELECT id FROM artifacts
|
|
1402
|
+
WHERE owner_type = 'change' AND owner_id = ? AND kind = 'execution_plan'
|
|
1403
|
+
AND status = 'current'
|
|
1404
|
+
ORDER BY updated_at DESC, rowid DESC`,
|
|
1405
|
+
).all(change.id);
|
|
1406
|
+
if (planArtifact.length !== 1) blockers.add('CHANGE_PLAN_AUTHORITY_MISSING');
|
|
1115
1407
|
}
|
|
1116
1408
|
const openIncidents = db.prepare(
|
|
1117
1409
|
`SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'
|
|
@@ -1124,7 +1416,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1124
1416
|
ops.tx(db, () => {
|
|
1125
1417
|
db.prepare("UPDATE changes SET status = 'blocked', updated_at = ? WHERE id = ?")
|
|
1126
1418
|
.run(nowIso(), change.id);
|
|
1127
|
-
if (diagnosis.artifact) upsertArtifact(db, diagnosis.artifact);
|
|
1419
|
+
if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
|
|
1128
1420
|
ops.appendEventInTx(db, {
|
|
1129
1421
|
type: 'change_blocked', change_id: change.id, payload: { blockers: blockerList },
|
|
1130
1422
|
});
|
|
@@ -1147,11 +1439,12 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1147
1439
|
ops.tx(db, () => {
|
|
1148
1440
|
db.prepare("UPDATE changes SET status = 'ready', updated_at = ? WHERE id = ?")
|
|
1149
1441
|
.run(nowIso(), change.id);
|
|
1150
|
-
if (diagnosis.artifact) upsertArtifact(db, diagnosis.artifact);
|
|
1442
|
+
if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
|
|
1151
1443
|
upsertArtifact(db, {
|
|
1152
1444
|
change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
|
|
1153
1445
|
contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
|
|
1154
1446
|
metadata: { evidence: derivedEvidence },
|
|
1447
|
+
rootDir,
|
|
1155
1448
|
});
|
|
1156
1449
|
ops.appendEventInTx(db, {
|
|
1157
1450
|
type: 'change_converged', change_id: change.id,
|
|
@@ -1161,19 +1454,240 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1161
1454
|
return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
|
|
1162
1455
|
}
|
|
1163
1456
|
|
|
1164
|
-
function
|
|
1457
|
+
function replaceArchiveAuthorityText(
|
|
1458
|
+
text,
|
|
1459
|
+
source,
|
|
1460
|
+
destination,
|
|
1461
|
+
digestMap = new Map(),
|
|
1462
|
+
{ normalizePath = false } = {},
|
|
1463
|
+
) {
|
|
1464
|
+
const original = String(text);
|
|
1465
|
+
const windowsSource = source.replaceAll('/', '\\');
|
|
1466
|
+
let result = original
|
|
1467
|
+
.replaceAll(source, destination)
|
|
1468
|
+
.replaceAll(windowsSource, destination);
|
|
1469
|
+
if (normalizePath && original.includes(windowsSource)) {
|
|
1470
|
+
result = result.replaceAll('\\', '/');
|
|
1471
|
+
}
|
|
1472
|
+
for (const [before, after] of digestMap) {
|
|
1473
|
+
if (before !== after) result = result.replaceAll(before, after);
|
|
1474
|
+
}
|
|
1475
|
+
return result;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function archiveRebindAuthorityValue(value, source, destination, digestMap) {
|
|
1479
|
+
if (typeof value === 'string') {
|
|
1480
|
+
return replaceArchiveAuthorityText(
|
|
1481
|
+
value, source, destination, digestMap, { normalizePath: true },
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
if (Array.isArray(value)) {
|
|
1485
|
+
return value.map(
|
|
1486
|
+
(item) => archiveRebindAuthorityValue(item, source, destination, digestMap),
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (value && typeof value === 'object') {
|
|
1490
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
1491
|
+
key,
|
|
1492
|
+
archiveRebindAuthorityValue(item, source, destination, digestMap),
|
|
1493
|
+
]));
|
|
1494
|
+
}
|
|
1495
|
+
return value;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function buildArchiveRebindEntries(db, intent, rootDir) {
|
|
1499
|
+
const tree = walkStableProjectTree(rootDir, intent.destination, {
|
|
1500
|
+
ignore: (relative) => (
|
|
1501
|
+
relative === `${intent.destination}/${archiveJournal.INTENT_FILE}`
|
|
1502
|
+
|| relative === `${intent.destination}/${archiveJournal.REBIND_FILE}`
|
|
1503
|
+
),
|
|
1504
|
+
});
|
|
1505
|
+
if (tree.unsafe.length > 0) {
|
|
1506
|
+
throw new ChangeWorkflowError(
|
|
1507
|
+
'ARCHIVE_PATH_UNSAFE',
|
|
1508
|
+
`archive packet contains unsafe entries: ${tree.unsafe.map((item) => item.path).join(', ')}`,
|
|
1509
|
+
{ unsafe: tree.unsafe },
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
const originalByPath = new Map();
|
|
1513
|
+
for (const artifact of db.prepare(
|
|
1514
|
+
'SELECT path, digest, content_hash FROM artifacts WHERE change_id = ?',
|
|
1515
|
+
).all(intent.change_id)) {
|
|
1516
|
+
const archivedPath = rebindRegistryPath(
|
|
1517
|
+
artifact.path, intent.source, intent.destination,
|
|
1518
|
+
);
|
|
1519
|
+
const digest = artifact.digest || artifact.content_hash;
|
|
1520
|
+
if (archivedPath && digest) originalByPath.set(archivedPath, digest);
|
|
1521
|
+
}
|
|
1522
|
+
const contextPaths = new Set();
|
|
1523
|
+
for (const snapshot of db.prepare(
|
|
1524
|
+
'SELECT manifest_path, manifest_hash FROM context_snapshots WHERE change_id = ?',
|
|
1525
|
+
).all(intent.change_id)) {
|
|
1526
|
+
const archivedPath = rebindRegistryPath(
|
|
1527
|
+
snapshot.manifest_path, intent.source, intent.destination,
|
|
1528
|
+
);
|
|
1529
|
+
if (archivedPath) {
|
|
1530
|
+
contextPaths.add(archivedPath);
|
|
1531
|
+
originalByPath.set(archivedPath, snapshot.manifest_hash);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const original = new Map();
|
|
1535
|
+
const pathRebound = new Map();
|
|
1536
|
+
for (const relative of tree.files) {
|
|
1537
|
+
const read = readStableProjectFile(rootDir, relative);
|
|
1538
|
+
original.set(relative, read.bytes);
|
|
1539
|
+
const text = read.bytes.toString('utf8');
|
|
1540
|
+
if (!Buffer.from(text, 'utf8').equals(read.bytes)) {
|
|
1541
|
+
if (read.bytes.includes(Buffer.from(intent.source))) {
|
|
1542
|
+
throw new ChangeWorkflowError(
|
|
1543
|
+
'ARCHIVE_REBIND_UNSUPPORTED',
|
|
1544
|
+
`archive packet contains a binary active-root reference: ${relative}`,
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
pathRebound.set(relative, read.bytes);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
let next = replaceArchiveAuthorityText(
|
|
1551
|
+
text, intent.source, intent.destination,
|
|
1552
|
+
);
|
|
1553
|
+
if (contextPaths.has(relative)) {
|
|
1554
|
+
let manifest;
|
|
1555
|
+
try { manifest = JSON.parse(next); } catch (error) {
|
|
1556
|
+
throw new ChangeWorkflowError(
|
|
1557
|
+
'ARCHIVE_CONTEXT_INVALID',
|
|
1558
|
+
`archived Context Manifest cannot be parsed: ${relative}: ${error.message}`,
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
if (manifest.change?.id !== intent.change_id) {
|
|
1562
|
+
throw new ChangeWorkflowError(
|
|
1563
|
+
'ARCHIVE_CONTEXT_INVALID',
|
|
1564
|
+
`archived Context Manifest belongs to another change: ${relative}`,
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
manifest.change.status = 'archived';
|
|
1568
|
+
next = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
1569
|
+
}
|
|
1570
|
+
pathRebound.set(relative, Buffer.from(next));
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
let current = new Map(pathRebound);
|
|
1574
|
+
for (let iteration = 0; iteration < 20; iteration += 1) {
|
|
1575
|
+
const digestMap = new Map();
|
|
1576
|
+
for (const [relative, beforeDigest] of originalByPath) {
|
|
1577
|
+
const bytes = current.get(relative);
|
|
1578
|
+
if (bytes) {
|
|
1579
|
+
digestMap.set(
|
|
1580
|
+
beforeDigest,
|
|
1581
|
+
crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
const next = new Map();
|
|
1586
|
+
let changed = false;
|
|
1587
|
+
for (const [relative, baseBytes] of pathRebound) {
|
|
1588
|
+
const text = baseBytes.toString('utf8');
|
|
1589
|
+
if (!Buffer.from(text, 'utf8').equals(baseBytes)) {
|
|
1590
|
+
next.set(relative, baseBytes);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
const bytes = Buffer.from(
|
|
1594
|
+
replaceArchiveAuthorityText(
|
|
1595
|
+
text, intent.source, intent.destination, digestMap,
|
|
1596
|
+
),
|
|
1597
|
+
);
|
|
1598
|
+
next.set(relative, bytes);
|
|
1599
|
+
if (!bytes.equals(current.get(relative))) changed = true;
|
|
1600
|
+
}
|
|
1601
|
+
current = next;
|
|
1602
|
+
if (!changed) break;
|
|
1603
|
+
if (iteration === 19) {
|
|
1604
|
+
throw new ChangeWorkflowError(
|
|
1605
|
+
'ARCHIVE_REBIND_CYCLE',
|
|
1606
|
+
'archive packet digest references did not converge',
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return [...original.entries()].flatMap(([relative, before]) => {
|
|
1611
|
+
const after = current.get(relative);
|
|
1612
|
+
if (after.equals(before)) return [];
|
|
1613
|
+
return [{
|
|
1614
|
+
relative_path: path.posix.relative(intent.destination, relative),
|
|
1615
|
+
before_digest: crypto.createHash('sha256').update(before).digest('hex'),
|
|
1616
|
+
after_digest: crypto.createHash('sha256').update(after).digest('hex'),
|
|
1617
|
+
before_base64: before.toString('base64'),
|
|
1618
|
+
after_base64: after.toString('base64'),
|
|
1619
|
+
}];
|
|
1620
|
+
}).sort((left, right) => left.relative_path.localeCompare(right.relative_path));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function archiveRebindState(db, intent, rootDir) {
|
|
1624
|
+
const existing = archiveJournal.readArchiveRebind(rootDir, intent);
|
|
1625
|
+
const entries = existing?.entries || buildArchiveRebindEntries(db, intent, rootDir);
|
|
1626
|
+
const journal = archiveJournal.prepareArchiveRebind(
|
|
1627
|
+
rootDir, intent, existing ? null : entries,
|
|
1628
|
+
);
|
|
1629
|
+
const byPath = new Map(journal.entries.map((entry) => [
|
|
1630
|
+
`${intent.destination}/${entry.relative_path}`,
|
|
1631
|
+
entry,
|
|
1632
|
+
]));
|
|
1633
|
+
const digestMap = new Map(journal.entries.map(
|
|
1634
|
+
(entry) => [entry.before_digest, entry.after_digest],
|
|
1635
|
+
));
|
|
1636
|
+
return { journal, byPath, digestMap };
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function finalizeArchive(db, intent, { rootDir, journalIntent = intent }) {
|
|
1165
1640
|
const change = readChange(db, intent.change_id);
|
|
1166
1641
|
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${intent.change_id} not found`);
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1642
|
+
try {
|
|
1643
|
+
archiveJournal.verifyArchiveIntent(rootDir, journalIntent, { location: 'destination' });
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
throw new ChangeWorkflowError(
|
|
1646
|
+
'ARCHIVE_PATH_UNSAFE',
|
|
1647
|
+
`archive transition cannot trust its physical destination: ${error.message}`,
|
|
1648
|
+
{ cause: error.code || error.message },
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
const relative = artifacts.normalizeRelativePath(intent.destination);
|
|
1652
|
+
const destination = safeRelativePath(rootDir, relative);
|
|
1653
|
+
if (change.status === 'archived'
|
|
1654
|
+
&& artifacts.normalizeRelativePath(change.artifact_root) === relative) {
|
|
1170
1655
|
return { change, archive_path: destination };
|
|
1171
1656
|
}
|
|
1172
1657
|
if (change.status !== 'ready') {
|
|
1173
1658
|
throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${change.id} must be ready to finish archive`);
|
|
1174
1659
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1660
|
+
const rebind = archiveRebindState(db, journalIntent, rootDir);
|
|
1661
|
+
const archivedReconciliationPath = rebindRegistryPath(
|
|
1662
|
+
intent.reconciliation_path, intent.source, relative,
|
|
1663
|
+
);
|
|
1664
|
+
const reconciliationRead = readStableProjectFile(
|
|
1665
|
+
rootDir, archivedReconciliationPath, { encoding: 'utf8' },
|
|
1666
|
+
);
|
|
1667
|
+
let reconciliationManifest;
|
|
1668
|
+
try { reconciliationManifest = JSON.parse(reconciliationRead.text); } catch (error) {
|
|
1669
|
+
throw new ChangeWorkflowError(
|
|
1670
|
+
'BASELINE_RECONCILIATION_MANIFEST_INVALID',
|
|
1671
|
+
`archived reconciliation manifest is invalid JSON: ${error.message}`,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
if (!validateReconciliationSchema(reconciliationManifest)
|
|
1675
|
+
|| reconciliationManifest.change_id !== change.id) {
|
|
1676
|
+
throw new ChangeWorkflowError(
|
|
1677
|
+
'BASELINE_RECONCILIATION_MANIFEST_INVALID',
|
|
1678
|
+
'archived reconciliation manifest no longer matches its accepted schema and Change',
|
|
1679
|
+
{ errors: validateReconciliationSchema.errors || [] },
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
const reconciliationDigest = reconciliationRead.digest;
|
|
1683
|
+
let summaryReader;
|
|
1684
|
+
try {
|
|
1685
|
+
summaryReader = openStableProjectRead(rootDir, `${relative}/archive-summary.md`);
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
throw new ChangeWorkflowError(
|
|
1688
|
+
'ARCHIVE_PATH_UNSAFE', 'archive summary cannot be read safely',
|
|
1689
|
+
{ cause: error.code || error.message },
|
|
1690
|
+
);
|
|
1177
1691
|
}
|
|
1178
1692
|
const baselineHealthBefore = baselines.inspectBaseline(db, { rootDir });
|
|
1179
1693
|
const breakGlass = Boolean(
|
|
@@ -1181,74 +1695,176 @@ function finalizeArchive(db, intent, { rootDir }) {
|
|
|
1181
1695
|
&& change.kind === 'incident'
|
|
1182
1696
|
&& change.baseline_bypass?.mode === 'incident_break_glass',
|
|
1183
1697
|
);
|
|
1698
|
+
try {
|
|
1184
1699
|
ops.tx(db, () => {
|
|
1185
1700
|
db.prepare(
|
|
1186
1701
|
"UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
|
|
1187
1702
|
).run(relative, nowIso(), nowIso(), change.id);
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1703
|
+
const artifactRows = db.prepare(
|
|
1704
|
+
`SELECT id, path, digest, content_hash, before_digest, metadata_json, provenance_json
|
|
1705
|
+
FROM artifacts WHERE change_id = ?`,
|
|
1706
|
+
).all(change.id);
|
|
1190
1707
|
for (const artifact of artifactRows) {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1708
|
+
const archivedPath = rebindRegistryPath(
|
|
1709
|
+
artifact.path, intent.source, relative,
|
|
1710
|
+
);
|
|
1711
|
+
if (!archivedPath) continue;
|
|
1712
|
+
const rebound = rebind.byPath.get(archivedPath);
|
|
1713
|
+
if (rebound) {
|
|
1714
|
+
db.prepare(
|
|
1715
|
+
`UPDATE artifacts
|
|
1716
|
+
SET digest = ?, content_hash = ?, before_digest = COALESCE(before_digest, ?),
|
|
1717
|
+
after_digest = ?, metadata_json = ?, provenance_json = ?
|
|
1718
|
+
WHERE id = ?`,
|
|
1719
|
+
).run(
|
|
1720
|
+
rebound.after_digest,
|
|
1721
|
+
rebound.after_digest,
|
|
1722
|
+
artifact.digest || artifact.content_hash || rebound.before_digest,
|
|
1723
|
+
rebound.after_digest,
|
|
1724
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1725
|
+
parseJson(artifact.metadata_json || '{}', 'artifacts.metadata_json'),
|
|
1726
|
+
intent.source,
|
|
1727
|
+
relative,
|
|
1728
|
+
rebind.digestMap,
|
|
1729
|
+
)),
|
|
1730
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1731
|
+
parseJson(artifact.provenance_json || '{}', 'artifacts.provenance_json'),
|
|
1732
|
+
intent.source,
|
|
1733
|
+
relative,
|
|
1734
|
+
rebind.digestMap,
|
|
1735
|
+
)),
|
|
1736
|
+
artifact.id,
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
artifacts.moveArtifactInTx(db, artifact.id, archivedPath, { rootDir });
|
|
1196
1740
|
}
|
|
1197
1741
|
const workflowOutputs = db.prepare(
|
|
1198
|
-
`SELECT ws.run_id, ws.step_id, ws.outputs_json
|
|
1742
|
+
`SELECT ws.run_id, ws.step_id, ws.outputs_json, ws.evidence_json,
|
|
1743
|
+
ws.decisions_json, ws.semantic_records_json, ws.blockers_json
|
|
1199
1744
|
FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
|
|
1200
1745
|
WHERE wr.change_id = ?`,
|
|
1201
1746
|
).all(change.id);
|
|
1202
1747
|
for (const step of workflowOutputs) {
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1748
|
+
const rebound = Object.fromEntries([
|
|
1749
|
+
['outputs_json', parseJson(step.outputs_json, 'workflow_steps.outputs_json')],
|
|
1750
|
+
['evidence_json', parseJson(step.evidence_json, 'workflow_steps.evidence_json')],
|
|
1751
|
+
['decisions_json', parseJson(step.decisions_json, 'workflow_steps.decisions_json')],
|
|
1752
|
+
['semantic_records_json', parseJson(
|
|
1753
|
+
step.semantic_records_json, 'workflow_steps.semantic_records_json',
|
|
1754
|
+
)],
|
|
1755
|
+
['blockers_json', parseJson(step.blockers_json, 'workflow_steps.blockers_json')],
|
|
1756
|
+
].map(([field, value]) => [
|
|
1757
|
+
field,
|
|
1758
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1759
|
+
value, intent.source, relative, rebind.digestMap,
|
|
1760
|
+
)),
|
|
1761
|
+
]));
|
|
1762
|
+
db.prepare(
|
|
1763
|
+
`UPDATE workflow_steps
|
|
1764
|
+
SET outputs_json = ?, evidence_json = ?, decisions_json = ?,
|
|
1765
|
+
semantic_records_json = ?, blockers_json = ?, updated_at = ?
|
|
1766
|
+
WHERE run_id = ? AND step_id = ?`,
|
|
1767
|
+
).run(
|
|
1768
|
+
rebound.outputs_json,
|
|
1769
|
+
rebound.evidence_json,
|
|
1770
|
+
rebound.decisions_json,
|
|
1771
|
+
rebound.semantic_records_json,
|
|
1772
|
+
rebound.blockers_json,
|
|
1773
|
+
nowIso(),
|
|
1774
|
+
step.run_id,
|
|
1775
|
+
step.step_id,
|
|
1776
|
+
);
|
|
1216
1777
|
}
|
|
1217
1778
|
const contextRows = db.prepare(
|
|
1218
|
-
|
|
1779
|
+
`SELECT id, manifest_path, manifest_hash, context_json
|
|
1780
|
+
FROM context_snapshots WHERE change_id = ?`,
|
|
1219
1781
|
).all(change.id);
|
|
1220
1782
|
for (const snapshot of contextRows) {
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1783
|
+
const archivedPath = rebindRegistryPath(
|
|
1784
|
+
snapshot.manifest_path, intent.source, relative,
|
|
1785
|
+
);
|
|
1786
|
+
if (!archivedPath) continue;
|
|
1787
|
+
const rebound = rebind.byPath.get(archivedPath);
|
|
1788
|
+
db.prepare(
|
|
1789
|
+
`UPDATE context_snapshots
|
|
1790
|
+
SET manifest_path = ?, manifest_hash = ?, context_json = ? WHERE id = ?`,
|
|
1791
|
+
).run(
|
|
1792
|
+
archivedPath,
|
|
1793
|
+
rebound?.after_digest || snapshot.manifest_hash,
|
|
1794
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1795
|
+
parseJson(snapshot.context_json || '{}', 'context_snapshots.context_json'),
|
|
1796
|
+
intent.source,
|
|
1797
|
+
relative,
|
|
1798
|
+
rebind.digestMap,
|
|
1799
|
+
)),
|
|
1800
|
+
snapshot.id,
|
|
1801
|
+
);
|
|
1226
1802
|
}
|
|
1227
|
-
const
|
|
1803
|
+
for (const run of db.prepare(
|
|
1804
|
+
`SELECT id, metadata_json, blockers_json, summary_json, approval_json
|
|
1805
|
+
FROM workflow_runs WHERE change_id = ?`,
|
|
1806
|
+
).all(change.id)) {
|
|
1807
|
+
db.prepare(
|
|
1808
|
+
`UPDATE workflow_runs
|
|
1809
|
+
SET metadata_json = ?, blockers_json = ?, summary_json = ?, approval_json = ?
|
|
1810
|
+
WHERE id = ?`,
|
|
1811
|
+
).run(
|
|
1812
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1813
|
+
parseJson(run.metadata_json || '{}', 'workflow_runs.metadata_json'),
|
|
1814
|
+
intent.source, relative, rebind.digestMap,
|
|
1815
|
+
)),
|
|
1816
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1817
|
+
parseJson(run.blockers_json || '[]', 'workflow_runs.blockers_json'),
|
|
1818
|
+
intent.source, relative, rebind.digestMap,
|
|
1819
|
+
)),
|
|
1820
|
+
JSON.stringify(archiveRebindAuthorityValue(
|
|
1821
|
+
parseJson(run.summary_json || '{}', 'workflow_runs.summary_json'),
|
|
1822
|
+
intent.source, relative, rebind.digestMap,
|
|
1823
|
+
)),
|
|
1824
|
+
run.approval_json === null
|
|
1825
|
+
? null
|
|
1826
|
+
: JSON.stringify(archiveRebindAuthorityValue(
|
|
1827
|
+
parseJson(run.approval_json, 'workflow_runs.approval_json'),
|
|
1828
|
+
intent.source, relative, rebind.digestMap,
|
|
1829
|
+
)),
|
|
1830
|
+
run.id,
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
db.prepare(
|
|
1834
|
+
`UPDATE trace_links
|
|
1835
|
+
SET source_ref = REPLACE(source_ref, ?, ?),
|
|
1836
|
+
target_ref = REPLACE(target_ref, ?, ?)
|
|
1837
|
+
WHERE change_id = ?`,
|
|
1838
|
+
).run(intent.source, relative, intent.source, relative, change.id);
|
|
1228
1839
|
upsertArtifact(db, {
|
|
1229
1840
|
change_id: change.id, kind: 'archive_summary',
|
|
1230
|
-
artifactPath:
|
|
1231
|
-
contentHash:
|
|
1841
|
+
artifactPath: `${relative}/archive-summary.md`,
|
|
1842
|
+
contentHash: summaryReader.digest,
|
|
1232
1843
|
metadata: {
|
|
1233
1844
|
baseline_updates: intent.baseline_updates,
|
|
1234
1845
|
no_baseline_change_reason: intent.no_baseline_change_reason,
|
|
1235
1846
|
},
|
|
1847
|
+
terminal: true,
|
|
1848
|
+
rootDir,
|
|
1236
1849
|
});
|
|
1237
|
-
const reconciliationSuffix = path.relative(intent.source, intent.reconciliation_path);
|
|
1238
|
-
const archivedReconciliationPath = path.join(relative, reconciliationSuffix);
|
|
1239
1850
|
upsertArtifact(db, {
|
|
1240
1851
|
change_id: change.id, kind: 'baseline_reconciliation',
|
|
1241
1852
|
artifactPath: archivedReconciliationPath,
|
|
1242
|
-
contentHash:
|
|
1853
|
+
contentHash: reconciliationDigest,
|
|
1243
1854
|
metadata: {
|
|
1244
|
-
schema:
|
|
1245
|
-
semantic_changes:
|
|
1246
|
-
resolved_gap_ids:
|
|
1247
|
-
resolved_unknowns:
|
|
1855
|
+
schema: reconciliationManifest.$schema,
|
|
1856
|
+
semantic_changes: reconciliationManifest.semantic_changes.map((item) => item.id),
|
|
1857
|
+
resolved_gap_ids: reconciliationManifest.resolved_gap_ids,
|
|
1858
|
+
resolved_unknowns: reconciliationManifest.resolved_unknowns,
|
|
1248
1859
|
},
|
|
1860
|
+
terminal: true,
|
|
1861
|
+
rootDir,
|
|
1249
1862
|
});
|
|
1250
|
-
db.prepare(
|
|
1251
|
-
|
|
1863
|
+
for (const artifact of db.prepare(
|
|
1864
|
+
'SELECT id FROM artifacts WHERE change_id = ? ORDER BY id',
|
|
1865
|
+
).all(change.id)) {
|
|
1866
|
+
artifacts.setArtifactStatusInTx(db, artifact.id, 'archived');
|
|
1867
|
+
}
|
|
1252
1868
|
if (breakGlass) {
|
|
1253
1869
|
const currentBaseline = baselines.readBaseline(db);
|
|
1254
1870
|
if (currentBaseline) {
|
|
@@ -1268,7 +1884,8 @@ function finalizeArchive(db, intent, { rootDir }) {
|
|
|
1268
1884
|
} else {
|
|
1269
1885
|
baselines.reconcileBaseline(db, {
|
|
1270
1886
|
baseline_updates: intent.baseline_updates, change_id: change.id,
|
|
1271
|
-
reconciliation:
|
|
1887
|
+
reconciliation: reconciliationManifest,
|
|
1888
|
+
accept_delivery_worktree: true,
|
|
1272
1889
|
}, { rootDir });
|
|
1273
1890
|
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
1274
1891
|
if (baselineHealth.blockers.length > 0) {
|
|
@@ -1285,11 +1902,26 @@ function finalizeArchive(db, intent, { rootDir }) {
|
|
|
1285
1902
|
archive_path: relative, baseline_updates: intent.baseline_updates,
|
|
1286
1903
|
no_baseline_change_reason: intent.no_baseline_change_reason,
|
|
1287
1904
|
reconciliation_path: archivedReconciliationPath,
|
|
1288
|
-
reconciliation_digest:
|
|
1905
|
+
reconciliation_digest: reconciliationDigest,
|
|
1289
1906
|
baseline_bypass: breakGlass,
|
|
1290
1907
|
},
|
|
1291
1908
|
});
|
|
1909
|
+
summaryReader.verify();
|
|
1910
|
+
try {
|
|
1911
|
+
archiveJournal.verifyArchiveIntent(
|
|
1912
|
+
rootDir, journalIntent, { location: 'destination' },
|
|
1913
|
+
);
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
throw new ChangeWorkflowError(
|
|
1916
|
+
'ARCHIVE_PATH_UNSAFE',
|
|
1917
|
+
`archive transition changed physical identity before commit: ${error.message}`,
|
|
1918
|
+
{ cause: error.code || error.message },
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1292
1921
|
});
|
|
1922
|
+
} finally {
|
|
1923
|
+
summaryReader.close();
|
|
1924
|
+
}
|
|
1293
1925
|
return {
|
|
1294
1926
|
change: readChange(db, change.id), archive_path: destination,
|
|
1295
1927
|
baseline_bypass: breakGlass,
|
|
@@ -1302,30 +1934,107 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1302
1934
|
if (!input.summary || String(input.summary).trim().length < 3) {
|
|
1303
1935
|
throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
|
|
1304
1936
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1937
|
+
if (!['ready', 'archived'].includes(change.status)) {
|
|
1938
|
+
throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
|
|
1939
|
+
}
|
|
1940
|
+
if (change.status === 'archived') {
|
|
1941
|
+
deliveryTransaction.completeDeliveryTransaction({
|
|
1942
|
+
rootDir,
|
|
1943
|
+
changeId: change.id,
|
|
1944
|
+
});
|
|
1945
|
+
return {
|
|
1946
|
+
change,
|
|
1947
|
+
archive_path: safeRelativePath(rootDir, change.artifact_root),
|
|
1948
|
+
idempotent: true,
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
let packet = null;
|
|
1953
|
+
if (changePacket.readDeltaAuthority(db, change.id)) {
|
|
1954
|
+
workflows.validateDeliveryPrerequisites(db, { change_id: change.id }, rootDir);
|
|
1955
|
+
packet = changePacket.deliveryEntries(db, change, { rootDir });
|
|
1956
|
+
}
|
|
1957
|
+
const requestedUpdates = Array.isArray(input.baseline_updates)
|
|
1958
|
+
? input.baseline_updates.map((item) => artifacts.normalizeRelativePath(item))
|
|
1959
|
+
: null;
|
|
1960
|
+
const updates = packet
|
|
1961
|
+
? packet.baseline_updates
|
|
1962
|
+
: (requestedUpdates || []);
|
|
1963
|
+
if (packet && requestedUpdates
|
|
1964
|
+
&& JSON.stringify([...requestedUpdates].sort()) !== JSON.stringify([...updates].sort())) {
|
|
1965
|
+
throw new ChangeWorkflowError(
|
|
1966
|
+
'BASELINE_RECONCILIATION_CONFLICT',
|
|
1967
|
+
'baseline_updates must match the typed Change delta exactly',
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
const requestedNoChange = input.no_baseline_change_reason
|
|
1971
|
+
&& String(input.no_baseline_change_reason).trim();
|
|
1972
|
+
const noChangeReason = packet
|
|
1973
|
+
? (packet.delta.value.no_semantic_change_reason || requestedNoChange || null)
|
|
1974
|
+
: requestedNoChange;
|
|
1307
1975
|
if (updates.length === 0 && !noChangeReason) {
|
|
1308
|
-
throw new ChangeWorkflowError(
|
|
1976
|
+
throw new ChangeWorkflowError(
|
|
1977
|
+
'BASELINE_RECONCILIATION_REQUIRED',
|
|
1978
|
+
'baseline updates or no-change reason required',
|
|
1979
|
+
);
|
|
1309
1980
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1981
|
+
|
|
1982
|
+
const reconciliation = packet
|
|
1983
|
+
? generatePacketReconciliation(db, change, packet, rootDir)
|
|
1984
|
+
: readReconciliationManifest(db, change, input, rootDir);
|
|
1985
|
+
let delivery = null;
|
|
1986
|
+
let prepared = null;
|
|
1987
|
+
try {
|
|
1988
|
+
if (packet) {
|
|
1989
|
+
delivery = deliveryTransaction.beginDeliveryTransaction({
|
|
1990
|
+
rootDir,
|
|
1991
|
+
changeId: change.id,
|
|
1992
|
+
entries: packet.entries,
|
|
1993
|
+
});
|
|
1313
1994
|
}
|
|
1995
|
+
for (const file of updates) {
|
|
1996
|
+
if (!fs.existsSync(safeRelativePath(rootDir, file))) {
|
|
1997
|
+
throw new ChangeWorkflowError(
|
|
1998
|
+
'BASELINE_FILE_MISSING', `baseline update missing: ${file}`,
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
prepared = archiveJournal.prepareArchiveMove({
|
|
2003
|
+
rootDir, change, summary: String(input.summary), baselineUpdates: updates,
|
|
2004
|
+
noBaselineChangeReason: noChangeReason || null,
|
|
2005
|
+
reconciliationPath: reconciliation.relative,
|
|
2006
|
+
reconciliationDigest: reconciliation.journal_intent?.reconciliation_digest
|
|
2007
|
+
|| reconciliation.digest,
|
|
2008
|
+
reconciliationManifest: reconciliation.journal_intent?.reconciliation_manifest
|
|
2009
|
+
|| reconciliation.manifest,
|
|
2010
|
+
});
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
if (delivery) {
|
|
2013
|
+
try {
|
|
2014
|
+
deliveryTransaction.rollbackDeliveryTransaction({
|
|
2015
|
+
rootDir,
|
|
2016
|
+
changeId: change.id,
|
|
2017
|
+
});
|
|
2018
|
+
} catch (rollbackError) {
|
|
2019
|
+
throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
|
|
2020
|
+
cause: error.code || error.message,
|
|
2021
|
+
rollback: rollbackError.code || rollbackError.message,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
throw error;
|
|
1314
2026
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
rootDir, change, summary: String(input.summary), baselineUpdates: updates,
|
|
1321
|
-
noBaselineChangeReason: noChangeReason || null,
|
|
1322
|
-
reconciliationPath: reconciliation.relative,
|
|
1323
|
-
reconciliationDigest: reconciliation.digest,
|
|
1324
|
-
reconciliationManifest: reconciliation.manifest,
|
|
1325
|
-
});
|
|
2027
|
+
const authoritativeIntent = {
|
|
2028
|
+
...prepared.intent,
|
|
2029
|
+
reconciliation_digest: reconciliation.digest,
|
|
2030
|
+
reconciliation_manifest: reconciliation.manifest,
|
|
2031
|
+
};
|
|
1326
2032
|
let result;
|
|
1327
2033
|
try {
|
|
1328
|
-
result = finalizeArchive(db,
|
|
2034
|
+
result = finalizeArchive(db, authoritativeIntent, {
|
|
2035
|
+
rootDir,
|
|
2036
|
+
journalIntent: prepared.intent,
|
|
2037
|
+
});
|
|
1329
2038
|
} catch (error) {
|
|
1330
2039
|
try { archiveJournal.rollbackArchiveIntent(rootDir, prepared.intent); }
|
|
1331
2040
|
catch (rollbackError) {
|
|
@@ -1333,10 +2042,36 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
1333
2042
|
cause: error.code || error.message, rollback: rollbackError.code || rollbackError.message,
|
|
1334
2043
|
});
|
|
1335
2044
|
}
|
|
2045
|
+
if (delivery) {
|
|
2046
|
+
try {
|
|
2047
|
+
deliveryTransaction.rollbackDeliveryTransaction({
|
|
2048
|
+
rootDir,
|
|
2049
|
+
changeId: change.id,
|
|
2050
|
+
});
|
|
2051
|
+
} catch (rollbackError) {
|
|
2052
|
+
throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
|
|
2053
|
+
cause: error.code || error.message,
|
|
2054
|
+
rollback: rollbackError.code || rollbackError.message,
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
1336
2058
|
throw error;
|
|
1337
2059
|
}
|
|
1338
2060
|
try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
|
|
1339
2061
|
catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
|
|
2062
|
+
if (delivery) {
|
|
2063
|
+
try {
|
|
2064
|
+
deliveryTransaction.completeDeliveryTransaction({
|
|
2065
|
+
rootDir,
|
|
2066
|
+
changeId: change.id,
|
|
2067
|
+
});
|
|
2068
|
+
} catch (error) {
|
|
2069
|
+
result.recovery_warning = [
|
|
2070
|
+
result.recovery_warning,
|
|
2071
|
+
`DELIVERY_JOURNAL_CLEANUP_PENDING:${error.message}`,
|
|
2072
|
+
].filter(Boolean).join(';');
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
1340
2075
|
return result;
|
|
1341
2076
|
}
|
|
1342
2077
|
|
|
@@ -1355,9 +2090,20 @@ function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
|
|
|
1355
2090
|
archiveJournal.completeArchiveIntent(rootDir, record.intent);
|
|
1356
2091
|
result.cleaned += 1;
|
|
1357
2092
|
result.items.push({ change_id: change.id, status: 'cleaned' });
|
|
1358
|
-
} else if (change?.status === 'ready' &&
|
|
1359
|
-
|
|
1360
|
-
|
|
2093
|
+
} else if (change?.status === 'ready' && record.location === 'destination') {
|
|
2094
|
+
const reconciliation = readReconciliationManifest(db, change, {
|
|
2095
|
+
reconciliation_path: record.intent.reconciliation_path,
|
|
2096
|
+
baseline_updates: record.intent.baseline_updates,
|
|
2097
|
+
}, rootDir);
|
|
2098
|
+
const authoritativeIntent = {
|
|
2099
|
+
...record.intent,
|
|
2100
|
+
reconciliation_digest: reconciliation.digest,
|
|
2101
|
+
reconciliation_manifest: reconciliation.manifest,
|
|
2102
|
+
};
|
|
2103
|
+
finalizeArchive(db, authoritativeIntent, {
|
|
2104
|
+
rootDir,
|
|
2105
|
+
journalIntent: record.intent,
|
|
2106
|
+
});
|
|
1361
2107
|
archiveJournal.completeArchiveIntent(rootDir, record.intent);
|
|
1362
2108
|
result.resumed += 1;
|
|
1363
2109
|
result.items.push({ change_id: change.id, status: 'resumed' });
|
|
@@ -1383,6 +2129,14 @@ function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
|
|
|
1383
2129
|
}
|
|
1384
2130
|
}
|
|
1385
2131
|
}
|
|
2132
|
+
const archivedChangeIds = new Set(
|
|
2133
|
+
db.prepare("SELECT id FROM changes WHERE status = 'archived'").all().map((row) => row.id),
|
|
2134
|
+
);
|
|
2135
|
+
result.delivery = deliveryTransaction.recoverDeliveryTransactions({
|
|
2136
|
+
rootDir,
|
|
2137
|
+
archivedChangeIds,
|
|
2138
|
+
});
|
|
2139
|
+
result.failed += result.delivery.failed;
|
|
1386
2140
|
return result;
|
|
1387
2141
|
}
|
|
1388
2142
|
|
|
@@ -1392,6 +2146,8 @@ module.exports = {
|
|
|
1392
2146
|
readChange,
|
|
1393
2147
|
listChanges,
|
|
1394
2148
|
updateChange,
|
|
2149
|
+
recordDelta,
|
|
2150
|
+
recordDocumentationReconciliation,
|
|
1395
2151
|
compileContext,
|
|
1396
2152
|
readBreadcrumb: contextSpine.readBreadcrumb,
|
|
1397
2153
|
proposeSpecLearning: specLearning.proposeSpecLearning,
|