ultra-builder-pro-cli 0.15.0 → 0.17.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/specs/research-distillate.md +4 -3
- package/CHANGELOG.md +120 -1
- package/README.md +67 -30
- package/adapters/_shared/codex-assets.cjs +10 -5
- package/adapters/_shared/handbook.cjs +3 -2
- package/adapters/_shared/interaction-contract.cjs +61 -0
- package/adapters/claude.js +1 -1
- package/adapters/opencode.js +1 -1
- package/bin/orchestrator.js +175 -19
- package/commands/ultra-change.md +1 -1
- package/commands/ultra-deliver.md +3 -3
- package/commands/ultra-init.md +1 -1
- package/commands/ultra-plan.md +1 -1
- package/commands/ultra-research.md +2 -2
- package/commands/ultra-review.md +10 -0
- package/commands/ultra-status.md +1 -1
- package/commands/ultra-test.md +1 -1
- package/commands/ultra-think.md +2 -2
- package/docs/AGENT-CONTEXT.md +38 -18
- package/docs/ARCHITECTURE.md +91 -13
- package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
- package/docs/PLAN.zh-CN.md +19 -12
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +8 -6
- package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
- package/docs/WORKFLOW-LIFECYCLE.md +179 -39
- package/hooks/pre_stop_check.py +3 -2
- package/mcp-server/lib/baseline-workflow.cjs +84 -16
- package/mcp-server/lib/change-workflow.cjs +107 -39
- package/mcp-server/lib/context-spine.cjs +215 -87
- package/mcp-server/lib/decision-dialogue.cjs +713 -0
- package/mcp-server/lib/doctor.cjs +9 -0
- package/mcp-server/lib/git-bootstrap.cjs +240 -0
- package/mcp-server/lib/init-project.cjs +178 -41
- package/mcp-server/lib/project-breadcrumb.cjs +23 -14
- package/mcp-server/lib/state-db.cjs +279 -9
- package/mcp-server/lib/state-ops.cjs +64 -1
- package/mcp-server/lib/workflow-state.cjs +313 -205
- package/mcp-server/server.cjs +112 -34
- package/orchestrator/auto-merge.cjs +19 -0
- package/orchestrator/daemon.cjs +104 -0
- package/orchestrator/parallel-orchestrator.cjs +371 -49
- package/orchestrator/session-runner.cjs +354 -25
- package/package.json +2 -1
- package/skills/ultra-change/SKILL.md +69 -65
- package/skills/ultra-deliver/SKILL.md +37 -58
- package/skills/ultra-dev/SKILL.md +55 -49
- package/skills/ultra-doctor/SKILL.md +44 -49
- package/skills/ultra-init/SKILL.md +78 -100
- package/skills/ultra-plan/SKILL.md +78 -79
- package/skills/ultra-research/SKILL.md +99 -98
- package/skills/ultra-review/SKILL.md +38 -47
- package/skills/ultra-review/references/review-modes.md +5 -3
- package/skills/ultra-review/references/unified-schema.md +26 -6
- package/skills/ultra-review/scripts/review_wait.py +12 -0
- package/skills/ultra-status/SKILL.md +34 -40
- package/skills/ultra-test/SKILL.md +61 -48
- package/skills/ultra-think/SKILL.md +47 -47
- package/skills/ultra-think/references/decision-dialogue.md +106 -0
- package/spec/cli-protocol.md +46 -2
- package/spec/mcp-tools.yaml +444 -29
- package/spec/schemas/delivery-report.v1.schema.json +1 -46
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +66 -2
- package/spec/schemas/test-report.v1.schema.json +45 -14
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/session.cjs +10 -3
- package/ultra-tools/commands/status.cjs +34 -14
- package/ultra-tools/commands/system.cjs +2 -2
- package/ultra-tools/commands/task.cjs +3 -0
|
@@ -8,10 +8,11 @@ const Ajv = require('ajv/dist/2020');
|
|
|
8
8
|
const addFormats = require('ajv-formats');
|
|
9
9
|
|
|
10
10
|
const ops = require('./state-ops.cjs');
|
|
11
|
+
const decisionDialogue = require('./decision-dialogue.cjs');
|
|
11
12
|
const testReportSchema = require('../../spec/schemas/test-report.v1.schema.json');
|
|
12
13
|
const deliveryReportSchema = require('../../spec/schemas/delivery-report.v1.schema.json');
|
|
13
14
|
|
|
14
|
-
const DEFINITION_VERSION = '
|
|
15
|
+
const DEFINITION_VERSION = '2.0';
|
|
15
16
|
const RUN_KINDS = new Set(['init', 'research', 'plan', 'change', 'dev', 'test', 'review', 'deliver']);
|
|
16
17
|
const RUN_STATUSES = new Set(['active', 'blocked', 'ready', 'completed', 'cancelled']);
|
|
17
18
|
const STEP_STATUSES = new Set(['pending', 'in_progress', 'completed', 'skipped', 'blocked']);
|
|
@@ -32,8 +33,7 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
|
32
33
|
step('inspect-authority', 'Inspect repository and existing Ultra authority'),
|
|
33
34
|
step('classify-repository', 'Classify greenfield, brownfield, or migrated state'),
|
|
34
35
|
step('scaffold-authority', 'Create or resume the authoritative state and scaffold'),
|
|
35
|
-
step('
|
|
36
|
-
step('verify-initialization', 'Verify authority, projections, and the next route', { evidence_required: true }),
|
|
36
|
+
step('verify-initialization', 'Verify authority, projections, and the initialization result', { evidence_required: true }),
|
|
37
37
|
]),
|
|
38
38
|
research: Object.freeze([
|
|
39
39
|
step('00-problem-validation', 'Validate the problem and demand evidence', { evidence_required: true, output_required: true }),
|
|
@@ -56,12 +56,10 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
|
56
56
|
]),
|
|
57
57
|
plan: Object.freeze([
|
|
58
58
|
step('validate-baseline', 'Validate baseline and research coverage', { evidence_required: true }),
|
|
59
|
-
step('select-posture', 'Record the owner-selected planning posture', { evidence_required: true }),
|
|
60
59
|
step('analyze-requirements', 'Trace requirements and acceptance'),
|
|
61
60
|
step('analyze-codebase', 'Inspect current codebase patterns and boundaries', { evidence_required: true }),
|
|
62
61
|
step('design-slices', 'Design walking skeleton and vertical slices'),
|
|
63
62
|
step('validate-dependencies', 'Validate ownership, dependencies, and parallel waves', { evidence_required: true }),
|
|
64
|
-
step('approve-plan', 'Obtain explicit plan approval', { evidence_required: true }),
|
|
65
63
|
step('persist-task-contracts', 'Persist tasks and complete execution contracts', { evidence_required: true }),
|
|
66
64
|
step('verify-plan', 'Read back and verify the authoritative plan', {
|
|
67
65
|
evidence_required: true, output_required: true,
|
|
@@ -71,9 +69,6 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
|
71
69
|
step('bind-baseline', 'Bind current baseline and repository revision', { evidence_required: true }),
|
|
72
70
|
step('classify-change', 'Classify quick, standard, major, or incident'),
|
|
73
71
|
step('record-intent', 'Record intent, scope, acceptance, and documentation impact', { evidence_required: true }),
|
|
74
|
-
step('plan-change', 'Create or validate change tasks'),
|
|
75
|
-
step('compile-context', 'Compile bounded role context', { evidence_required: true, output_required: true }),
|
|
76
|
-
step('verify-readiness', 'Verify one executable next action', { evidence_required: true }),
|
|
77
72
|
]),
|
|
78
73
|
dev: Object.freeze([
|
|
79
74
|
step('bind-task', 'Bind the authoritative task and dependencies', { evidence_required: true }),
|
|
@@ -88,10 +83,10 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
|
88
83
|
step('bind-scope', 'Bind completed tasks and current revision', { evidence_required: true }),
|
|
89
84
|
step('compile-context', 'Compile independent checking context', { evidence_required: true, output_required: true }),
|
|
90
85
|
step('map-acceptance', 'Map acceptance claims to executable checks'),
|
|
91
|
-
step('execute-checks', 'Execute
|
|
86
|
+
step('execute-checks', 'Execute the risk-selected verification profile', { evidence_required: true }),
|
|
92
87
|
step('verify-public-seam', 'Verify the declared public seam', { evidence_required: true }),
|
|
93
88
|
step('write-report', 'Write the current test evidence report', { evidence_required: true, output_required: true }),
|
|
94
|
-
step('verify-test-gate', 'Verify report freshness and
|
|
89
|
+
step('verify-test-gate', 'Verify report freshness and expose valid transitions', { evidence_required: true }),
|
|
95
90
|
]),
|
|
96
91
|
review: Object.freeze([
|
|
97
92
|
step('bind-diff', 'Bind one current diff and acceptance scope', { evidence_required: true }),
|
|
@@ -104,14 +99,17 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
|
104
99
|
deliver: Object.freeze([
|
|
105
100
|
step('bind-evidence', 'Bind current test, review, task, and revision evidence', { evidence_required: true }),
|
|
106
101
|
step('reconcile-specifications', 'Resolve learning and reconcile specifications', { evidence_required: true }),
|
|
107
|
-
step('verify-candidate', 'Verify the
|
|
102
|
+
step('verify-candidate', 'Verify the local delivery candidate and recovery path', { evidence_required: true, output_required: true }),
|
|
108
103
|
step('converge-authority', 'Converge baseline or change authority', { evidence_required: true }),
|
|
109
104
|
step('archive-change', 'Archive the converged change when applicable', { evidence_required: true }),
|
|
110
|
-
step('
|
|
111
|
-
step('verify-delivery', 'Verify local and remote delivery evidence', { evidence_required: true, output_required: true }),
|
|
105
|
+
step('verify-delivery', 'Verify local delivery and recovery evidence', { evidence_required: true, output_required: true }),
|
|
112
106
|
]),
|
|
113
107
|
});
|
|
114
108
|
|
|
109
|
+
const RESEARCH_DISPOSITIONS = new Set([
|
|
110
|
+
'execute', 'verify_existing', 'reuse', 'not_applicable', 'deferred',
|
|
111
|
+
]);
|
|
112
|
+
|
|
115
113
|
const RESEARCH_MODES = Object.freeze({
|
|
116
114
|
full: WORKFLOW_DEFINITIONS.research.map((item) => item.id),
|
|
117
115
|
adoption: WORKFLOW_DEFINITIONS.research.map((item) => item.id),
|
|
@@ -379,32 +377,6 @@ function validateSynthesisTrace(db, runRow, semanticRecords) {
|
|
|
379
377
|
}
|
|
380
378
|
}
|
|
381
379
|
|
|
382
|
-
function validateReleaseAuthorizationDecision(decisions) {
|
|
383
|
-
if (!Array.isArray(decisions) || decisions.length !== 1) {
|
|
384
|
-
throw new WorkflowStateError(
|
|
385
|
-
'WORKFLOW_RELEASE_AUTHORIZATION_INVALID',
|
|
386
|
-
'release-if-authorized requires exactly one release_authorization decision',
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
const decision = decisions[0];
|
|
390
|
-
if (decision?.kind !== 'release_authorization'
|
|
391
|
-
|| typeof decision.authorized !== 'boolean'
|
|
392
|
-
|| !String(decision.reason || '').trim()) {
|
|
393
|
-
throw new WorkflowStateError(
|
|
394
|
-
'WORKFLOW_RELEASE_AUTHORIZATION_INVALID',
|
|
395
|
-
'release authorization requires kind, authorized, and reason',
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
if (decision.authorized === true
|
|
399
|
-
&& (!String(decision.approved_by || '').trim() || !String(decision.scope || '').trim())) {
|
|
400
|
-
throw new WorkflowStateError(
|
|
401
|
-
'WORKFLOW_RELEASE_AUTHORIZATION_INVALID',
|
|
402
|
-
'an authorized release requires approved_by and an explicit release scope',
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
return decision;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
380
|
function validateResearchStepReport(run, stepId, outputs, rootDir) {
|
|
409
381
|
const expected = path.join(
|
|
410
382
|
'.ultra', 'docs', 'research', run.id, `${stepId}.md`,
|
|
@@ -601,7 +573,95 @@ function listWorkflows(db, filter = {}, { rootDir = process.cwd() } = {}) {
|
|
|
601
573
|
return rows.map((row) => decorateRun(db, row, rootDir));
|
|
602
574
|
}
|
|
603
575
|
|
|
604
|
-
function
|
|
576
|
+
function normalizeResearchCoverage(mode, coverage, selectedSteps, metadata = {}) {
|
|
577
|
+
const knownSteps = WORKFLOW_DEFINITIONS.research.map((item) => item.id);
|
|
578
|
+
const known = new Set(knownSteps);
|
|
579
|
+
if (coverage !== undefined) {
|
|
580
|
+
if (!Array.isArray(coverage) || coverage.length === 0) {
|
|
581
|
+
throw new WorkflowStateError(
|
|
582
|
+
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
583
|
+
'research coverage must be a non-empty array of semantic dispositions',
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const seen = new Set();
|
|
587
|
+
const normalized = coverage.map((item) => {
|
|
588
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
589
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'research coverage entries must be objects');
|
|
590
|
+
}
|
|
591
|
+
const stepId = String(item.step_id || '').trim();
|
|
592
|
+
const disposition = String(item.disposition || '').trim();
|
|
593
|
+
const rationale = String(item.rationale || '').trim();
|
|
594
|
+
const evidenceRefs = Array.isArray(item.evidence_refs)
|
|
595
|
+
? [...new Set(item.evidence_refs.map((ref) => String(ref).trim()).filter(Boolean))]
|
|
596
|
+
: [];
|
|
597
|
+
if (!known.has(stepId)) {
|
|
598
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `unknown research step: ${stepId || '(missing)'}`);
|
|
599
|
+
}
|
|
600
|
+
if (seen.has(stepId)) {
|
|
601
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `duplicate research coverage: ${stepId}`);
|
|
602
|
+
}
|
|
603
|
+
if (!RESEARCH_DISPOSITIONS.has(disposition)) {
|
|
604
|
+
throw new WorkflowStateError(
|
|
605
|
+
'VALIDATION_ERROR',
|
|
606
|
+
`unsupported research disposition for ${stepId}: ${disposition || '(missing)'}`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (rationale.length < 3) {
|
|
610
|
+
throw new WorkflowStateError(
|
|
611
|
+
'VALIDATION_ERROR', `research coverage ${stepId} requires a rationale`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (['verify_existing', 'reuse', 'not_applicable'].includes(disposition)
|
|
615
|
+
&& evidenceRefs.length === 0) {
|
|
616
|
+
throw new WorkflowStateError(
|
|
617
|
+
'WORKFLOW_RESEARCH_COVERAGE_EVIDENCE_REQUIRED',
|
|
618
|
+
`${stepId} ${disposition} requires at least one evidence reference`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const acceptedBy = item.accepted_by === undefined ? null : String(item.accepted_by).trim();
|
|
622
|
+
const consequence = item.consequence === undefined ? null : String(item.consequence).trim();
|
|
623
|
+
if (disposition === 'deferred' && (!consequence || consequence.length < 3)) {
|
|
624
|
+
throw new WorkflowStateError(
|
|
625
|
+
'VALIDATION_ERROR', `${stepId} deferred coverage requires a consequence`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
seen.add(stepId);
|
|
629
|
+
return {
|
|
630
|
+
step_id: stepId,
|
|
631
|
+
disposition,
|
|
632
|
+
rationale,
|
|
633
|
+
evidence_refs: evidenceRefs,
|
|
634
|
+
...(acceptedBy ? { accepted_by: acceptedBy } : {}),
|
|
635
|
+
...(consequence ? { consequence } : {}),
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
if (['full', 'adoption'].includes(mode)) {
|
|
639
|
+
const missing = knownSteps.filter((stepId) => !seen.has(stepId));
|
|
640
|
+
if (missing.length > 0) {
|
|
641
|
+
throw new WorkflowStateError(
|
|
642
|
+
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
643
|
+
`${mode} research must disposition every semantic area: ${missing.join(', ')}`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const synthesis = normalized.find((item) => item.step_id === '99-synthesis');
|
|
647
|
+
if (!synthesis || !['execute', 'verify_existing', 'reuse'].includes(synthesis.disposition)) {
|
|
648
|
+
throw new WorkflowStateError(
|
|
649
|
+
'WORKFLOW_RESEARCH_SYNTHESIS_REQUIRED',
|
|
650
|
+
`${mode} research requires an executable or reusable 99-synthesis disposition`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return normalized.sort(
|
|
655
|
+
(left, right) => knownSteps.indexOf(left.step_id) - knownSteps.indexOf(right.step_id),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (['full', 'adoption'].includes(mode)) {
|
|
660
|
+
throw new WorkflowStateError(
|
|
661
|
+
'WORKFLOW_RESEARCH_COVERAGE_REQUIRED',
|
|
662
|
+
`${mode} research requires model-selected coverage dispositions`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
605
665
|
if (mode === 'custom') {
|
|
606
666
|
if (!Array.isArray(selectedSteps) || selectedSteps.length === 0) {
|
|
607
667
|
throw new WorkflowStateError('VALIDATION_ERROR', 'custom research requires selected_steps');
|
|
@@ -609,11 +669,21 @@ function selectedResearchSteps(mode, selectedSteps) {
|
|
|
609
669
|
const known = new Set(WORKFLOW_DEFINITIONS.research.map((item) => item.id));
|
|
610
670
|
const invalid = selectedSteps.find((item) => !known.has(item));
|
|
611
671
|
if (invalid) throw new WorkflowStateError('VALIDATION_ERROR', `unknown research step: ${invalid}`);
|
|
612
|
-
return [...new Set([...selectedSteps, '99-synthesis'])]
|
|
672
|
+
return [...new Set([...selectedSteps, '99-synthesis'])].map((stepId) => ({
|
|
673
|
+
step_id: stepId,
|
|
674
|
+
disposition: 'execute',
|
|
675
|
+
rationale: String(metadata.selection_reason || 'Explicit bounded research selection.').trim(),
|
|
676
|
+
evidence_refs: [],
|
|
677
|
+
}));
|
|
613
678
|
}
|
|
614
679
|
const selected = RESEARCH_MODES[mode];
|
|
615
680
|
if (!selected) throw new WorkflowStateError('VALIDATION_ERROR', `unsupported research mode: ${mode}`);
|
|
616
|
-
return selected
|
|
681
|
+
return selected.map((stepId) => ({
|
|
682
|
+
step_id: stepId,
|
|
683
|
+
disposition: 'execute',
|
|
684
|
+
rationale: String(metadata.selection_reason || `${mode} research profile selected by the host model.`).trim(),
|
|
685
|
+
evidence_refs: [],
|
|
686
|
+
}));
|
|
617
687
|
}
|
|
618
688
|
|
|
619
689
|
function assertReference(db, table, id, field) {
|
|
@@ -631,9 +701,10 @@ function assertWorkflowAuthority(db, input, mode) {
|
|
|
631
701
|
);
|
|
632
702
|
}
|
|
633
703
|
};
|
|
634
|
-
if (input.kind !== 'research'
|
|
704
|
+
if (input.kind !== 'research'
|
|
705
|
+
&& (input.mode !== undefined || input.selected_steps !== undefined || input.coverage !== undefined)) {
|
|
635
706
|
throw new WorkflowStateError(
|
|
636
|
-
'VALIDATION_ERROR', 'mode and
|
|
707
|
+
'VALIDATION_ERROR', 'mode, selected_steps, and coverage are valid only for research workflows',
|
|
637
708
|
);
|
|
638
709
|
}
|
|
639
710
|
if (input.kind === 'init') requireField('baseline_id');
|
|
@@ -731,9 +802,10 @@ function assertPlanWorkflowAuthority(db, input) {
|
|
|
731
802
|
ORDER BY completed_at DESC, rowid DESC LIMIT 1`,
|
|
732
803
|
).get(change.id, disposition.mode);
|
|
733
804
|
const research = row ? readWorkflow(db, row.id, { rootDir: input.root_dir || process.cwd() }) : null;
|
|
734
|
-
const expectedSteps = new Set(
|
|
735
|
-
disposition.
|
|
736
|
-
|
|
805
|
+
const expectedSteps = new Set([
|
|
806
|
+
...(Array.isArray(disposition.selected_steps) ? disposition.selected_steps : []),
|
|
807
|
+
'99-synthesis',
|
|
808
|
+
]);
|
|
737
809
|
const actualSteps = new Set(
|
|
738
810
|
(research?.steps || []).filter((item) => item.required).map((item) => item.step_id),
|
|
739
811
|
);
|
|
@@ -826,6 +898,15 @@ function insertWorkflowInTx(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
826
898
|
assertReference(db, 'baselines', input.baseline_id, 'baseline_id');
|
|
827
899
|
assertReference(db, 'changes', input.change_id, 'change_id');
|
|
828
900
|
assertReference(db, 'tasks', input.task_id, 'task_id');
|
|
901
|
+
if (input.task_id && input.change_id) {
|
|
902
|
+
const taskOwner = db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(input.task_id);
|
|
903
|
+
if (taskOwner?.change_id !== input.change_id) {
|
|
904
|
+
throw new WorkflowStateError(
|
|
905
|
+
'TASK_CHANGE_OWNERSHIP_MISMATCH',
|
|
906
|
+
`task ${input.task_id} belongs to ${taskOwner?.change_id || '(none)'}, not ${input.change_id}`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
829
910
|
if (input.kind === 'plan') assertPlanWorkflowAuthority(db, { ...input, root_dir: rootDir });
|
|
830
911
|
if (input.kind === 'dev') {
|
|
831
912
|
if (!input.task_id) {
|
|
@@ -872,11 +953,22 @@ function insertWorkflowInTx(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
872
953
|
throw new WorkflowStateError('WORKFLOW_IN_PROGRESS', `workflow ${active.id} is already active`);
|
|
873
954
|
}
|
|
874
955
|
|
|
956
|
+
const researchCoverage = input.kind === 'research'
|
|
957
|
+
? normalizeResearchCoverage(mode, input.coverage, input.selected_steps, input.metadata)
|
|
958
|
+
: null;
|
|
959
|
+
const coverageByStep = new Map((researchCoverage || []).map((item) => [item.step_id, item]));
|
|
875
960
|
const selected = new Set(input.kind === 'research'
|
|
876
|
-
?
|
|
961
|
+
? researchCoverage
|
|
962
|
+
.filter((item) => ['execute', 'verify_existing', 'reuse'].includes(item.disposition)
|
|
963
|
+
|| (item.disposition === 'deferred' && !item.accepted_by))
|
|
964
|
+
.map((item) => item.step_id)
|
|
877
965
|
: definition.map((item) => item.id));
|
|
878
966
|
let metadata = input.kind === 'research'
|
|
879
|
-
? {
|
|
967
|
+
? {
|
|
968
|
+
...(input.metadata || {}),
|
|
969
|
+
selected_steps: [...selected],
|
|
970
|
+
coverage: researchCoverage,
|
|
971
|
+
}
|
|
880
972
|
: (input.metadata || {});
|
|
881
973
|
if (input.kind === 'plan') {
|
|
882
974
|
const profile = db.prepare('SELECT kind FROM changes WHERE id = ?').get(input.change_id)?.kind;
|
|
@@ -901,11 +993,31 @@ function insertWorkflowInTx(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
901
993
|
);
|
|
902
994
|
definition.forEach((item, position) => {
|
|
903
995
|
const required = selected.has(item.id);
|
|
996
|
+
const coverage = coverageByStep.get(item.id);
|
|
997
|
+
const disposition = coverage?.disposition || null;
|
|
998
|
+
const deferred = disposition === 'deferred' && !coverage.accepted_by;
|
|
999
|
+
const skippedReason = coverage && !required
|
|
1000
|
+
? `${disposition}: ${coverage.rationale}${coverage.accepted_by ? ` (accepted by ${coverage.accepted_by})` : ''}`
|
|
1001
|
+
: (required ? null : `Excluded by ${mode} mode.`);
|
|
904
1002
|
insertStep.run(
|
|
905
1003
|
id, item.id, position, item.title, required ? 1 : 0,
|
|
906
|
-
|
|
1004
|
+
deferred ? 'blocked' : (required ? 'pending' : 'skipped'), skippedReason, ts,
|
|
907
1005
|
);
|
|
908
1006
|
});
|
|
1007
|
+
if (researchCoverage?.some((item) => item.disposition === 'deferred' && !item.accepted_by)) {
|
|
1008
|
+
const blockers = researchCoverage
|
|
1009
|
+
.filter((item) => item.disposition === 'deferred' && !item.accepted_by)
|
|
1010
|
+
.map((item) => `RESEARCH_DEFERRED:${item.step_id}`);
|
|
1011
|
+
db.prepare(
|
|
1012
|
+
"UPDATE workflow_runs SET status = 'blocked', blockers_json = ? WHERE id = ?",
|
|
1013
|
+
).run(JSON.stringify(blockers), id);
|
|
1014
|
+
for (const blocker of blockers) {
|
|
1015
|
+
const stepId = blocker.split(':')[1];
|
|
1016
|
+
db.prepare(
|
|
1017
|
+
'UPDATE workflow_steps SET blockers_json = ? WHERE run_id = ? AND step_id = ?',
|
|
1018
|
+
).run(JSON.stringify([blocker]), id, stepId);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
909
1021
|
ops.appendEventInTx(db, {
|
|
910
1022
|
type: 'workflow_started', task_id: input.task_id, change_id: input.change_id,
|
|
911
1023
|
payload: { workflow_id: id, kind: input.kind, mode, baseline_id: input.baseline_id || null },
|
|
@@ -947,6 +1059,13 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
947
1059
|
`cannot transition ${id}/${stepId} from ${stepRow.status} to ${input.status}`,
|
|
948
1060
|
);
|
|
949
1061
|
}
|
|
1062
|
+
if (input.status === 'completed') {
|
|
1063
|
+
decisionDialogue.assertDecisionGate(db, {
|
|
1064
|
+
workflow_run_id: runRow.id,
|
|
1065
|
+
change_id: runRow.change_id,
|
|
1066
|
+
baseline_id: runRow.baseline_id,
|
|
1067
|
+
}, { rootDir });
|
|
1068
|
+
}
|
|
950
1069
|
const firstOpen = db.prepare(
|
|
951
1070
|
`SELECT step_id FROM workflow_steps
|
|
952
1071
|
WHERE run_id = ? AND required = 1 AND status NOT IN ('completed', 'skipped')
|
|
@@ -1000,7 +1119,7 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1000
1119
|
throw new WorkflowStateError('WORKFLOW_BLOCKER_REQUIRED', `${stepId} requires a blocker code`);
|
|
1001
1120
|
}
|
|
1002
1121
|
if (input.status === 'completed' && runRow.kind === 'test' && stepId === 'bind-scope') {
|
|
1003
|
-
assertDevelopmentComplete(db, runRow.change_id, rootDir);
|
|
1122
|
+
assertDevelopmentComplete(db, runRow.change_id, rootDir, runRow.task_id);
|
|
1004
1123
|
}
|
|
1005
1124
|
if (input.status === 'completed' && runRow.kind === 'dev' && stepId === 'review-slice') {
|
|
1006
1125
|
assertApprovedReview(db, runRow.change_id, runRow.task_id, rootDir);
|
|
@@ -1008,11 +1127,6 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1008
1127
|
if (input.status === 'completed' && runRow.kind === 'deliver' && stepId === 'bind-evidence') {
|
|
1009
1128
|
validateDeliveryPrerequisites(db, { change_id: runRow.change_id }, rootDir);
|
|
1010
1129
|
}
|
|
1011
|
-
if (input.status === 'completed' && runRow.kind === 'deliver'
|
|
1012
|
-
&& stepId === 'release-if-authorized') {
|
|
1013
|
-
validateReleaseAuthorizationDecision(decisions);
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
1130
|
const ts = nowIso();
|
|
1017
1131
|
const startedAt = stepRow.started_at || ts;
|
|
1018
1132
|
const completedAt = input.status === 'completed' ? ts : null;
|
|
@@ -1115,7 +1229,7 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
|
|
|
1115
1229
|
let value;
|
|
1116
1230
|
try { value = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
|
|
1117
1231
|
catch { continue; }
|
|
1118
|
-
if (value?.schema_version === '
|
|
1232
|
+
if (value?.schema_version === '3.0' && value?.snapshot_id) {
|
|
1119
1233
|
candidate = output;
|
|
1120
1234
|
manifest = value;
|
|
1121
1235
|
break;
|
|
@@ -1123,7 +1237,7 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
|
|
|
1123
1237
|
}
|
|
1124
1238
|
if (!candidate) {
|
|
1125
1239
|
throw new WorkflowStateError(
|
|
1126
|
-
'WORKFLOW_CONTEXT_OUTPUT_REQUIRED', `${run.kind} requires a Context Manifest
|
|
1240
|
+
'WORKFLOW_CONTEXT_OUTPUT_REQUIRED', `${run.kind} requires a current Context Manifest output`,
|
|
1127
1241
|
);
|
|
1128
1242
|
}
|
|
1129
1243
|
if (manifest.change?.id !== run.change_id
|
|
@@ -1231,16 +1345,22 @@ function assertCurrentPlan(db, changeId, rootDir) {
|
|
|
1231
1345
|
'WORKFLOW_PLAN_TASK_SET_STALE', `change ${changeId} task set changed after planning`,
|
|
1232
1346
|
);
|
|
1233
1347
|
}
|
|
1234
|
-
const
|
|
1235
|
-
if (!
|
|
1348
|
+
const plannedDigests = plan.summary.task_contract_digests;
|
|
1349
|
+
if (!plannedDigests || typeof plannedDigests !== 'object' || Array.isArray(plannedDigests)) {
|
|
1236
1350
|
throw new WorkflowStateError(
|
|
1237
1351
|
'WORKFLOW_PLAN_TASK_CONTRACT_STALE', `change ${changeId} plan has no task-contract provenance`,
|
|
1238
1352
|
);
|
|
1239
1353
|
}
|
|
1240
1354
|
for (const task of tasks) {
|
|
1241
|
-
if (
|
|
1355
|
+
if (task.stale) {
|
|
1242
1356
|
throw new WorkflowStateError(
|
|
1243
|
-
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
1357
|
+
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
1358
|
+
`task ${task.id} was invalidated by newer change authority and must be reconciled`,
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
if (plannedDigests[task.id] !== taskPlanDigest(task)) {
|
|
1362
|
+
throw new WorkflowStateError(
|
|
1363
|
+
'WORKFLOW_PLAN_TASK_CONTRACT_STALE', `task ${task.id} changed after plan convergence`,
|
|
1244
1364
|
);
|
|
1245
1365
|
}
|
|
1246
1366
|
}
|
|
@@ -1300,12 +1420,23 @@ function assertCompletedDevelopmentEvidence(run, task, rootDir) {
|
|
|
1300
1420
|
return run;
|
|
1301
1421
|
}
|
|
1302
1422
|
|
|
1303
|
-
function assertDevelopmentComplete(db, changeId, rootDir) {
|
|
1304
|
-
const tasks =
|
|
1423
|
+
function assertDevelopmentComplete(db, changeId, rootDir, taskId = null) {
|
|
1424
|
+
const tasks = taskId
|
|
1425
|
+
? [ops.readTask(db, taskId)].filter((task) => task?.change_id === changeId)
|
|
1426
|
+
: changeTasks(db, changeId);
|
|
1305
1427
|
if (tasks.length === 0) {
|
|
1306
|
-
throw new WorkflowStateError(
|
|
1428
|
+
throw new WorkflowStateError(
|
|
1429
|
+
'WORKFLOW_TASKS_REQUIRED',
|
|
1430
|
+
taskId ? `task ${taskId} is not owned by change ${changeId}` : `change ${changeId} has no tasks`,
|
|
1431
|
+
);
|
|
1307
1432
|
}
|
|
1308
1433
|
for (const task of tasks) {
|
|
1434
|
+
if (task.stale) {
|
|
1435
|
+
throw new WorkflowStateError(
|
|
1436
|
+
'WORKFLOW_GATE_STALE',
|
|
1437
|
+
`task ${task.id} was invalidated by newer change authority and must be replanned`,
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1309
1440
|
if (!['completed', 'expanded'].includes(task.status)) {
|
|
1310
1441
|
throw new WorkflowStateError(
|
|
1311
1442
|
'WORKFLOW_TASK_NOT_COMPLETED', `task ${task.id} is ${task.status}`,
|
|
@@ -1380,8 +1511,30 @@ function validateDevCompletion(db, run, rootDir) {
|
|
|
1380
1511
|
}
|
|
1381
1512
|
|
|
1382
1513
|
function validateChangeCompletion(db, run, rootDir) {
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1514
|
+
const change = db.prepare(
|
|
1515
|
+
`SELECT id, kind, intent, contract_json, classification_json, research_disposition_json
|
|
1516
|
+
FROM changes WHERE id = ?`,
|
|
1517
|
+
).get(run.change_id);
|
|
1518
|
+
if (!change) {
|
|
1519
|
+
throw new WorkflowStateError(
|
|
1520
|
+
'WORKFLOW_CHANGE_NOT_FOUND', `change ${run.change_id || '(missing)'} does not exist`,
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
const contract = parseJson(change.contract_json, 'changes.contract_json', {});
|
|
1524
|
+
const classification = parseJson(change.classification_json, 'changes.classification_json', {});
|
|
1525
|
+
const researchDisposition = parseJson(
|
|
1526
|
+
change.research_disposition_json, 'changes.research_disposition_json', {},
|
|
1527
|
+
);
|
|
1528
|
+
return {
|
|
1529
|
+
change_id: change.id,
|
|
1530
|
+
change_kind: change.kind,
|
|
1531
|
+
acceptance_ids: Array.isArray(contract.acceptance)
|
|
1532
|
+
? contract.acceptance.map((item) => item.id)
|
|
1533
|
+
: [],
|
|
1534
|
+
classification,
|
|
1535
|
+
research_disposition: researchDisposition,
|
|
1536
|
+
authority_basis: 'accepted_change_contract',
|
|
1537
|
+
};
|
|
1385
1538
|
}
|
|
1386
1539
|
|
|
1387
1540
|
function validateDeliveryPrerequisites(db, run, rootDir) {
|
|
@@ -1444,13 +1597,56 @@ function assertCurrentCheckout(report, rootDir) {
|
|
|
1444
1597
|
return checkout;
|
|
1445
1598
|
}
|
|
1446
1599
|
|
|
1600
|
+
const VERIFICATION_DIMENSIONS = Object.freeze([
|
|
1601
|
+
'acceptance',
|
|
1602
|
+
'regression',
|
|
1603
|
+
'integration',
|
|
1604
|
+
'static_analysis',
|
|
1605
|
+
'build',
|
|
1606
|
+
'performance',
|
|
1607
|
+
'security',
|
|
1608
|
+
'recovery',
|
|
1609
|
+
]);
|
|
1610
|
+
|
|
1611
|
+
function validateVerificationProfile(report) {
|
|
1612
|
+
const profile = report.verification_profile;
|
|
1613
|
+
const selected = new Set(profile?.selected_dimensions || []);
|
|
1614
|
+
const dimensions = new Set(Object.keys(report.verification_dimensions || {}));
|
|
1615
|
+
const excludedItems = Array.isArray(profile?.excluded_dimensions)
|
|
1616
|
+
? profile.excluded_dimensions : [];
|
|
1617
|
+
const excluded = new Set(excludedItems.map((item) => item.dimension));
|
|
1618
|
+
const known = new Set(VERIFICATION_DIMENSIONS);
|
|
1619
|
+
const sameSet = (left, right) => left.size === right.size
|
|
1620
|
+
&& [...left].every((item) => right.has(item));
|
|
1621
|
+
const invalid = [...selected, ...dimensions, ...excluded].some((item) => !known.has(item));
|
|
1622
|
+
const overlap = [...selected].some((item) => excluded.has(item));
|
|
1623
|
+
const duplicateExclusion = excluded.size !== excludedItems.length;
|
|
1624
|
+
const completeDisposition = VERIFICATION_DIMENSIONS.every(
|
|
1625
|
+
(dimension) => selected.has(dimension) || excluded.has(dimension),
|
|
1626
|
+
);
|
|
1627
|
+
if (!profile || typeof profile.rationale !== 'string' || profile.rationale.trim().length < 3
|
|
1628
|
+
|| !selected.has('acceptance') || !sameSet(selected, dimensions) || invalid || overlap
|
|
1629
|
+
|| duplicateExclusion || !completeDisposition) {
|
|
1630
|
+
throw new WorkflowStateError(
|
|
1631
|
+
'WORKFLOW_VERIFICATION_PROFILE_INVALID',
|
|
1632
|
+
'verification profile must select exactly the recorded dimensions, include acceptance, and explain every excluded dimension',
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
return {
|
|
1636
|
+
rationale: profile.rationale.trim(),
|
|
1637
|
+
selected_dimensions: [...selected],
|
|
1638
|
+
excluded_dimensions: excludedItems,
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1447
1642
|
function validateTestCompletion(db, run, rootDir) {
|
|
1448
|
-
assertDevelopmentComplete(db, run.change_id, rootDir);
|
|
1643
|
+
assertDevelopmentComplete(db, run.change_id, rootDir, run.task_id);
|
|
1449
1644
|
const context = recordedContext(db, run, rootDir, { current: true });
|
|
1450
1645
|
const { value: report, output } = readOutputJson(
|
|
1451
1646
|
run, 'write-report', rootDir, 'ultra-test-report-v1',
|
|
1452
1647
|
);
|
|
1453
1648
|
assertReportSchema(report, 'test');
|
|
1649
|
+
const verificationProfile = validateVerificationProfile(report);
|
|
1454
1650
|
if (report.change_id !== run.change_id) {
|
|
1455
1651
|
throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'test report change_id is not bound to the workflow');
|
|
1456
1652
|
}
|
|
@@ -1497,9 +1693,7 @@ function validateTestCompletion(db, run, rootDir) {
|
|
|
1497
1693
|
&& report.public_seams.every((item) => item?.status === 'pass')
|
|
1498
1694
|
&& report.failures.every((item) => item.status === 'resolved')
|
|
1499
1695
|
&& report.recovery.every((item) => item.status === 'pass')
|
|
1500
|
-
&& Object.values(report.verification_dimensions || {}).every(
|
|
1501
|
-
(item) => ['pass', 'not_applicable'].includes(item?.status),
|
|
1502
|
-
)
|
|
1696
|
+
&& Object.values(report.verification_dimensions || {}).every((item) => item?.status === 'pass')
|
|
1503
1697
|
&& report.blocking_issues.length === 0;
|
|
1504
1698
|
if (typeof report.passed !== 'boolean' || report.passed !== claimedPass) {
|
|
1505
1699
|
throw new WorkflowStateError(
|
|
@@ -1517,12 +1711,21 @@ function validateTestCompletion(db, run, rootDir) {
|
|
|
1517
1711
|
context_path: context.output.path,
|
|
1518
1712
|
context_digest: context.output.digest,
|
|
1519
1713
|
regression_signal: report.regression_signal,
|
|
1714
|
+
verification_profile: verificationProfile,
|
|
1520
1715
|
verification_dimensions: report.verification_dimensions,
|
|
1521
1716
|
blocking_issues: report.blocking_issues,
|
|
1522
1717
|
};
|
|
1523
1718
|
}
|
|
1524
1719
|
|
|
1525
1720
|
const REVIEW_AXES = new Set(['spec_fidelity', 'engineering_standards']);
|
|
1721
|
+
const REVIEW_WORKERS = new Set([
|
|
1722
|
+
'review-spec',
|
|
1723
|
+
'review-code',
|
|
1724
|
+
'review-tests',
|
|
1725
|
+
'review-errors',
|
|
1726
|
+
'review-design',
|
|
1727
|
+
'review-comments',
|
|
1728
|
+
]);
|
|
1526
1729
|
const REVIEW_SEVERITIES = new Set(['P0', 'P1', 'P2', 'P3']);
|
|
1527
1730
|
const REVIEW_FINDING_FIELDS = [
|
|
1528
1731
|
'id', 'axis', 'severity', 'category', 'title', 'file', 'line',
|
|
@@ -1566,6 +1769,7 @@ function readReviewSpecialists(run, rootDir) {
|
|
|
1566
1769
|
const checkout = currentCheckout(rootDir);
|
|
1567
1770
|
const expectedHead = checkout.head || 'workspace';
|
|
1568
1771
|
const seenIds = new Set();
|
|
1772
|
+
const seenAgents = new Set();
|
|
1569
1773
|
const artifacts = [];
|
|
1570
1774
|
for (const [stepId, expectedAxis] of axes) {
|
|
1571
1775
|
const outputs = run.steps.find((item) => item.step_id === stepId)?.outputs || [];
|
|
@@ -1595,6 +1799,13 @@ function readReviewSpecialists(run, rootDir) {
|
|
|
1595
1799
|
'WORKFLOW_REPORT_INVALID', `${output.path} is not a complete ${expectedAxis} artifact`,
|
|
1596
1800
|
);
|
|
1597
1801
|
}
|
|
1802
|
+
if (!REVIEW_WORKERS.has(artifact.agent) || seenAgents.has(artifact.agent)) {
|
|
1803
|
+
throw new WorkflowStateError(
|
|
1804
|
+
'WORKFLOW_REPORT_INVALID',
|
|
1805
|
+
`${output.path} uses an unknown or duplicate review worker: ${artifact.agent}`,
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
seenAgents.add(artifact.agent);
|
|
1598
1809
|
for (const finding of artifact.findings) {
|
|
1599
1810
|
validateReviewFinding(finding, expectedAxis, seenIds, output.path);
|
|
1600
1811
|
}
|
|
@@ -1605,6 +1816,8 @@ function readReviewSpecialists(run, rootDir) {
|
|
|
1605
1816
|
}
|
|
1606
1817
|
|
|
1607
1818
|
function assertCoordinatedReviewEvidence(report, specialists) {
|
|
1819
|
+
const sameSet = (left, right) => left.size === right.size
|
|
1820
|
+
&& [...left].every((item) => right.has(item));
|
|
1608
1821
|
const artifactPathsByAxis = Object.fromEntries([...REVIEW_AXES].map((axis) => [
|
|
1609
1822
|
axis,
|
|
1610
1823
|
specialists.artifacts.filter((artifact) => artifact.axis === axis).map((artifact) => artifact.path).sort(),
|
|
@@ -1625,15 +1838,12 @@ function assertCoordinatedReviewEvidence(report, specialists) {
|
|
|
1625
1838
|
);
|
|
1626
1839
|
}
|
|
1627
1840
|
const completed = new Set(report.workers?.completed || []);
|
|
1628
|
-
|
|
1841
|
+
const artifactAgents = new Set(specialists.artifacts.map((artifact) => artifact.agent));
|
|
1842
|
+
if (!sameSet(completed, artifactAgents)
|
|
1629
1843
|
|| (report.workers?.failed || []).length > 0) {
|
|
1630
1844
|
throw new WorkflowStateError(
|
|
1631
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
1632
|
-
|
|
1633
|
-
}
|
|
1634
|
-
if ((report.workers?.skipped || []).length > 0 && (report.limitations || []).length === 0) {
|
|
1635
|
-
throw new WorkflowStateError(
|
|
1636
|
-
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'skipped review workers require a recorded limitation',
|
|
1845
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
1846
|
+
'completed review workers must match specialist artifacts exactly',
|
|
1637
1847
|
);
|
|
1638
1848
|
}
|
|
1639
1849
|
const selection = Array.isArray(report.worker_selection) ? report.worker_selection : [];
|
|
@@ -1656,13 +1866,13 @@ function assertCoordinatedReviewEvidence(report, specialists) {
|
|
|
1656
1866
|
...(report.workers?.completed || []), ...(report.workers?.failed || []),
|
|
1657
1867
|
]);
|
|
1658
1868
|
const expectedSkipped = new Set(report.workers?.skipped || []);
|
|
1659
|
-
|
|
1660
|
-
|
|
1869
|
+
if (!sameSet(seenWorkers, REVIEW_WORKERS)
|
|
1870
|
+
|| !sameSet(selected, expectedSelected)
|
|
1661
1871
|
|| !sameSet(skipped, expectedSkipped)
|
|
1662
1872
|
|| !selected.has('review-spec') || !completed.has('review-spec')) {
|
|
1663
1873
|
throw new WorkflowStateError(
|
|
1664
1874
|
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
1665
|
-
'review worker selection must match completed, failed, and skipped
|
|
1875
|
+
'review worker selection must disposition the complete worker roster and match completed, failed, and skipped state',
|
|
1666
1876
|
);
|
|
1667
1877
|
}
|
|
1668
1878
|
|
|
@@ -1791,6 +2001,12 @@ function validateDeliveryCompletion(db, run, rootDir) {
|
|
|
1791
2001
|
const { value: report, output } = readOutputJson(
|
|
1792
2002
|
run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
|
|
1793
2003
|
);
|
|
2004
|
+
if (report.release !== undefined) {
|
|
2005
|
+
throw new WorkflowStateError(
|
|
2006
|
+
'WORKFLOW_RELEASE_AUTHORITY_MISMATCH',
|
|
2007
|
+
'delivery reports cannot create release authority; record external effects through the host after explicit authorization',
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
1794
2010
|
assertReportSchema(report, 'delivery');
|
|
1795
2011
|
if (report.change_id !== run.change_id || report.archive_status !== 'archived') {
|
|
1796
2012
|
throw new WorkflowStateError(
|
|
@@ -1809,24 +2025,6 @@ function validateDeliveryCompletion(db, run, rootDir) {
|
|
|
1809
2025
|
|| report.checks.some((item) => item?.status !== 'pass')) {
|
|
1810
2026
|
throw new WorkflowStateError('WORKFLOW_DELIVERY_CHECK_FAILED', 'delivery checks must all pass');
|
|
1811
2027
|
}
|
|
1812
|
-
if (!report.release || typeof report.release !== 'object'
|
|
1813
|
-
|| typeof report.release.authorized !== 'boolean'
|
|
1814
|
-
|| typeof report.release.performed !== 'boolean') {
|
|
1815
|
-
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'delivery report release decision is incomplete');
|
|
1816
|
-
}
|
|
1817
|
-
if (report.release.authorized !== report.release.performed) {
|
|
1818
|
-
throw new WorkflowStateError(
|
|
1819
|
-
'WORKFLOW_RELEASE_INCOMPLETE', 'an authorized release must complete; an unauthorized release must not run',
|
|
1820
|
-
);
|
|
1821
|
-
}
|
|
1822
|
-
const releaseStep = run.steps.find((item) => item.step_id === 'release-if-authorized');
|
|
1823
|
-
const releaseDecision = validateReleaseAuthorizationDecision(releaseStep?.decisions || []);
|
|
1824
|
-
if (report.release.authorized !== releaseDecision.authorized) {
|
|
1825
|
-
throw new WorkflowStateError(
|
|
1826
|
-
'WORKFLOW_RELEASE_AUTHORITY_MISMATCH',
|
|
1827
|
-
'delivery report release claim does not match the recorded owner authorization decision',
|
|
1828
|
-
);
|
|
1829
|
-
}
|
|
1830
2028
|
if (!String(report.rollback || '').trim()) {
|
|
1831
2029
|
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'delivery report rollback guidance is required');
|
|
1832
2030
|
}
|
|
@@ -1842,7 +2040,6 @@ function validateDeliveryCompletion(db, run, rootDir) {
|
|
|
1842
2040
|
baseline_status: baseline.status,
|
|
1843
2041
|
git_commit: checkout.head,
|
|
1844
2042
|
worktree_digest: checkout.digest,
|
|
1845
|
-
release: report.release,
|
|
1846
2043
|
report_path: output.path,
|
|
1847
2044
|
report_digest: output.digest,
|
|
1848
2045
|
context_path: context.output.path,
|
|
@@ -1902,24 +2099,6 @@ function validatePlanArtifact(db, run, rootDir) {
|
|
|
1902
2099
|
};
|
|
1903
2100
|
}
|
|
1904
2101
|
|
|
1905
|
-
function advanceLinkedChangeAfterPlan(db, run, rootDir) {
|
|
1906
|
-
if (run.kind !== 'plan' || !run.change_id) return;
|
|
1907
|
-
const linked = db.prepare(
|
|
1908
|
-
`SELECT id FROM workflow_runs
|
|
1909
|
-
WHERE kind = 'change' AND change_id = ? AND current_step = 'plan-change'
|
|
1910
|
-
AND status IN ('active', 'blocked')
|
|
1911
|
-
ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
|
|
1912
|
-
).get(run.change_id);
|
|
1913
|
-
if (!linked) return;
|
|
1914
|
-
recordWorkflowStep(db, {
|
|
1915
|
-
id: linked.id, step_id: 'plan-change', status: 'completed',
|
|
1916
|
-
evidence: [{
|
|
1917
|
-
kind: 'workflow', ref: run.id,
|
|
1918
|
-
summary: `Plan workflow ${run.id} completed with authoritative task contracts.`,
|
|
1919
|
-
}],
|
|
1920
|
-
}, { rootDir });
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
2102
|
function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1924
2103
|
const id = nonEmpty(input.id, 'id');
|
|
1925
2104
|
return ops.tx(db, () => {
|
|
@@ -1929,6 +2108,11 @@ function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1929
2108
|
if (run.status !== 'ready') {
|
|
1930
2109
|
throw new WorkflowStateError('WORKFLOW_NOT_READY', `workflow ${id} is ${run.status}`);
|
|
1931
2110
|
}
|
|
2111
|
+
decisionDialogue.assertDecisionGate(db, {
|
|
2112
|
+
workflow_run_id: run.id,
|
|
2113
|
+
change_id: run.change_id,
|
|
2114
|
+
baseline_id: run.baseline_id,
|
|
2115
|
+
}, { rootDir });
|
|
1932
2116
|
if (run.artifact_health.status !== 'pass') {
|
|
1933
2117
|
throw new WorkflowStateError('WORKFLOW_OUTPUT_STALE', 'workflow outputs are missing or stale', run.artifact_health);
|
|
1934
2118
|
}
|
|
@@ -1961,8 +2145,9 @@ function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1961
2145
|
task_contract_digests: contract.task_contract_digests,
|
|
1962
2146
|
profile: contract.profile,
|
|
1963
2147
|
coverage: contract.coverage,
|
|
2148
|
+
authority_basis: 'accepted_change_contract',
|
|
1964
2149
|
};
|
|
1965
|
-
validatePlanApproval(approval);
|
|
2150
|
+
if (approval !== null) validatePlanApproval(approval);
|
|
1966
2151
|
}
|
|
1967
2152
|
if (run.kind === 'dev' && ops.readTask(db, run.task_id)?.status !== 'completed') {
|
|
1968
2153
|
throw new WorkflowStateError(
|
|
@@ -1979,7 +2164,7 @@ function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1979
2164
|
type: 'workflow_completed', task_id: run.task_id, change_id: run.change_id,
|
|
1980
2165
|
payload: { workflow_id: id, kind: run.kind, baseline_id: run.baseline_id },
|
|
1981
2166
|
});
|
|
1982
|
-
if (run.kind === 'plan') {
|
|
2167
|
+
if (run.kind === 'plan' && approval !== null) {
|
|
1983
2168
|
ops.appendEventInTx(db, {
|
|
1984
2169
|
type: 'plan_approved', change_id: run.change_id,
|
|
1985
2170
|
payload: {
|
|
@@ -1992,7 +2177,6 @@ function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
|
1992
2177
|
},
|
|
1993
2178
|
});
|
|
1994
2179
|
}
|
|
1995
|
-
advanceLinkedChangeAfterPlan(db, run, rootDir);
|
|
1996
2180
|
return readWorkflow(db, id, { rootDir });
|
|
1997
2181
|
});
|
|
1998
2182
|
}
|
|
@@ -2051,6 +2235,12 @@ function validatePlanContract(db, run, rootDir = process.cwd()) {
|
|
|
2051
2235
|
);
|
|
2052
2236
|
}
|
|
2053
2237
|
ops.assertTaskExecutionContract(task);
|
|
2238
|
+
if (task.stale) {
|
|
2239
|
+
throw new WorkflowStateError(
|
|
2240
|
+
'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
|
|
2241
|
+
`task ${id} was invalidated by newer change authority and must be reconciled`,
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2054
2244
|
return task;
|
|
2055
2245
|
});
|
|
2056
2246
|
const changeRow = db.prepare(
|
|
@@ -2115,88 +2305,6 @@ function validatePlanContract(db, run, rootDir = process.cwd()) {
|
|
|
2115
2305
|
};
|
|
2116
2306
|
}
|
|
2117
2307
|
|
|
2118
|
-
function activeInitializationRun(db, baselineId) {
|
|
2119
|
-
return db.prepare(
|
|
2120
|
-
`SELECT * FROM workflow_runs
|
|
2121
|
-
WHERE kind = 'init' AND baseline_id = ? AND status IN ('active', 'blocked', 'ready')
|
|
2122
|
-
ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
|
|
2123
|
-
).get(baselineId);
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
function blockInitializationInTx(db, baselineId, blockers = []) {
|
|
2127
|
-
const run = activeInitializationRun(db, baselineId);
|
|
2128
|
-
if (!run) return null;
|
|
2129
|
-
const normalized = [...new Set(blockers.map((item) => String(item).trim()).filter(Boolean))];
|
|
2130
|
-
const ts = nowIso();
|
|
2131
|
-
db.prepare(
|
|
2132
|
-
`UPDATE workflow_steps SET status = 'blocked', blockers_json = ?,
|
|
2133
|
-
started_at = COALESCE(started_at, ?), completed_at = NULL, updated_at = ?
|
|
2134
|
-
WHERE run_id = ? AND step_id = 'establish-baseline'`,
|
|
2135
|
-
).run(JSON.stringify(normalized), ts, ts, run.id);
|
|
2136
|
-
db.prepare(
|
|
2137
|
-
`UPDATE workflow_runs SET status = 'blocked', current_step = 'establish-baseline',
|
|
2138
|
-
blockers_json = ?, updated_at = ? WHERE id = ?`,
|
|
2139
|
-
).run(JSON.stringify(normalized), ts, run.id);
|
|
2140
|
-
ops.appendEventInTx(db, {
|
|
2141
|
-
type: 'workflow_blocked',
|
|
2142
|
-
payload: {
|
|
2143
|
-
workflow_id: run.id, kind: 'init', step_id: 'establish-baseline',
|
|
2144
|
-
status: 'blocked', blockers: normalized,
|
|
2145
|
-
},
|
|
2146
|
-
});
|
|
2147
|
-
return run.id;
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
function completeInitializationInTx(db, baseline) {
|
|
2151
|
-
const run = activeInitializationRun(db, baseline.id);
|
|
2152
|
-
if (!run) return null;
|
|
2153
|
-
const ts = nowIso();
|
|
2154
|
-
const steps = [
|
|
2155
|
-
{
|
|
2156
|
-
id: 'establish-baseline',
|
|
2157
|
-
evidence: [{
|
|
2158
|
-
kind: 'baseline', ref: baseline.id,
|
|
2159
|
-
summary: `Baseline ${baseline.id} converged at revision ${baseline.repository_revision}.`,
|
|
2160
|
-
}],
|
|
2161
|
-
},
|
|
2162
|
-
{
|
|
2163
|
-
id: 'verify-initialization',
|
|
2164
|
-
evidence: [{
|
|
2165
|
-
kind: 'state', ref: '.ultra/state.db',
|
|
2166
|
-
summary: 'Baseline authority, workflow state, and the post-initialization route are ready.',
|
|
2167
|
-
}],
|
|
2168
|
-
},
|
|
2169
|
-
];
|
|
2170
|
-
for (const item of steps) {
|
|
2171
|
-
db.prepare(
|
|
2172
|
-
`UPDATE workflow_steps SET status = 'completed', evidence_json = ?, blockers_json = '[]',
|
|
2173
|
-
started_at = COALESCE(started_at, ?), completed_at = ?, updated_at = ?
|
|
2174
|
-
WHERE run_id = ? AND step_id = ?`,
|
|
2175
|
-
).run(JSON.stringify(item.evidence), ts, ts, ts, run.id, item.id);
|
|
2176
|
-
ops.appendEventInTx(db, {
|
|
2177
|
-
type: 'workflow_step_updated',
|
|
2178
|
-
payload: { workflow_id: run.id, kind: 'init', step_id: item.id, status: 'completed' },
|
|
2179
|
-
});
|
|
2180
|
-
}
|
|
2181
|
-
db.prepare(
|
|
2182
|
-
`UPDATE workflow_runs SET status = 'completed', current_step = NULL, blockers_json = '[]',
|
|
2183
|
-
approval_json = ?, summary_json = ?, completed_at = ?, updated_at = ? WHERE id = ?`,
|
|
2184
|
-
).run(
|
|
2185
|
-
JSON.stringify({ approved_by: baseline.approved_by, approved_at: baseline.converged_at }),
|
|
2186
|
-
JSON.stringify({
|
|
2187
|
-
baseline_id: baseline.id,
|
|
2188
|
-
repository_revision: baseline.repository_revision,
|
|
2189
|
-
research_run_id: baseline.research_run_id,
|
|
2190
|
-
}),
|
|
2191
|
-
ts, ts, run.id,
|
|
2192
|
-
);
|
|
2193
|
-
ops.appendEventInTx(db, {
|
|
2194
|
-
type: 'workflow_completed',
|
|
2195
|
-
payload: { workflow_id: run.id, kind: 'init', baseline_id: baseline.id },
|
|
2196
|
-
});
|
|
2197
|
-
return run.id;
|
|
2198
|
-
}
|
|
2199
|
-
|
|
2200
2308
|
function inspectWorkflowHealth(db, { rootDir = process.cwd() } = {}) {
|
|
2201
2309
|
const all = listWorkflows(db, { limit: 500 }, { rootDir });
|
|
2202
2310
|
const active = all
|
|
@@ -2282,11 +2390,11 @@ module.exports = {
|
|
|
2282
2390
|
listWorkflows,
|
|
2283
2391
|
recordWorkflowStep,
|
|
2284
2392
|
completeWorkflow,
|
|
2285
|
-
blockInitializationInTx,
|
|
2286
|
-
completeInitializationInTx,
|
|
2287
2393
|
inspectWorkflowHealth,
|
|
2288
2394
|
recoverUntrackedChangeWorkflows,
|
|
2289
2395
|
validateDeliveryPrerequisites,
|
|
2290
2396
|
validatePlanContract,
|
|
2397
|
+
assertCurrentPlan,
|
|
2398
|
+
assertApprovedReview,
|
|
2291
2399
|
resolveProjectSourceRef: sourceRefFile,
|
|
2292
2400
|
};
|